From 512ede4182d0eb6607371f9a8bc249dfaaab153a Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Mon, 4 May 2026 20:07:21 -0400 Subject: [PATCH 01/27] Bump workflow actions to Node 24 versions (#310) Avoids GitHub's June 2, 2026 forced migration off Node 20: - actions/checkout v4 -> v5 - actions/setup-dotnet v4 -> v5 - actions/upload-artifact v4 -> v6 (first major to default to Node 24) - signpath/github-action-submit-signing-request v1 -> v2 Co-authored-by: Claude Opus 4.7 (1M context) --- .github/workflows/check-version-bump.yml | 4 ++-- .github/workflows/ci.yml | 4 ++-- .github/workflows/deploy-web.yml | 4 ++-- .github/workflows/nightly.yml | 6 +++--- .github/workflows/release.yml | 8 ++++---- 5 files changed, 13 insertions(+), 13 deletions(-) diff --git a/.github/workflows/check-version-bump.yml b/.github/workflows/check-version-bump.yml index b48de67..3cddd9d 100644 --- a/.github/workflows/check-version-bump.yml +++ b/.github/workflows/check-version-bump.yml @@ -10,7 +10,7 @@ jobs: steps: - name: Checkout PR branch - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Get PR version id: pr @@ -21,7 +21,7 @@ jobs: Write-Host "PR version: $version" - name: Checkout main - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: ref: main path: main-branch diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 19e94f2..26b6f82 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,10 +11,10 @@ jobs: runs-on: windows-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Setup .NET 8.0 - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v5 with: dotnet-version: 8.0.x diff --git a/.github/workflows/deploy-web.yml b/.github/workflows/deploy-web.yml index bbaed60..6036c5f 100644 --- a/.github/workflows/deploy-web.yml +++ b/.github/workflows/deploy-web.yml @@ -27,10 +27,10 @@ jobs: url: ${{ steps.deployment.outputs.page_url }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Setup .NET 8.0 - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v5 with: dotnet-version: 8.0.x diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index c035742..d6802ce 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -15,7 +15,7 @@ jobs: outputs: has_changes: ${{ steps.check.outputs.has_changes }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: ref: dev fetch-depth: 0 @@ -38,12 +38,12 @@ jobs: runs-on: windows-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: ref: dev - name: Setup .NET 8.0 - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v5 with: dotnet-version: 8.0.x diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2e269f5..3f76ee3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,7 +16,7 @@ jobs: runs-on: windows-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Get version id: version @@ -46,7 +46,7 @@ jobs: gh release create "v${{ steps.version.outputs.VERSION }}" --title "v${{ steps.version.outputs.VERSION }}" --generate-notes --target main - name: Setup .NET 8.0 - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v5 with: dotnet-version: 8.0.x @@ -78,14 +78,14 @@ jobs: - name: Upload Windows build for signing if: steps.signing.outputs.ENABLED == 'true' id: upload-unsigned - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: App-unsigned path: publish/win-x64/ - name: Sign Windows build if: steps.signing.outputs.ENABLED == 'true' - uses: signpath/github-action-submit-signing-request@v1 + uses: signpath/github-action-submit-signing-request@v2 with: api-token: '${{ secrets.SIGNPATH_API_TOKEN }}' organization-id: '7969f8b6-d946-4a74-9bac-a55856d8b8e0' From c90bff7a0719bd4ac8afef76df50dffac03fefa3 Mon Sep 17 00:00:00 2001 From: Romain Ferraton <16419423+rferraton@users.noreply.github.com> Date: Tue, 5 May 2026 14:53:23 +0200 Subject: [PATCH 02/27] Feature/multi query stores dashboard overview (#311) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * switch the default grid GroupBy from "None" to "Query Hash" and don't open the first line by default * The Query Stores Overview feature has been implemented. Here's a summary of what was created: New Files: 1. QueryStoreOverviewModels.cs — Models for: • QueryStoreState enum (Off, ReadOnly, ReadWrite) • DatabaseQueryStoreState — state per database • DatabaseMetrics — aggregated metrics (total + avg) per database • DatabaseTimeSlice — time slice data tagged by database • DatabaseWaitCategoryTimeSlice — wait stats tagged by database 2. QueryStoreOverviewService.cs — Parallel data fetching with: • SemaphoreSlim throttling (default DOP=8) • ConcurrentBag for thread-safe result collection • Methods: FetchAllStatesAsync(string, int, CancellationToken), FetchAllMetricsAsync(string, List, DateTime, DateTime, int, CancellationToken), FetchAllTimeSlicesAsync(string, List, int, int, CancellationToken), FetchAllWaitStatsAsync(string, List, DateTime, DateTime, int, CancellationToken) 3. QueryStoreOverviewControl.axaml — Layout with 3 rows: • Row 1: Donut chart + consolidated time slicer + consolidated wait stats ribbon • Row 2: 7 bar chart cards (Total metrics) • Row 3: 7 bar chart cards (Avg metrics) 4. QueryStoreOverviewControl.axaml.cs — Code-behind with: • Donut chart (RW=light blue, RO=dark blue, OFF=grey, center shows active/total) • Consolidated time slicer (30-day, 24h default selection) • Consolidated wait stats ribbon (sum across databases) • Top-N bar cards with consistent database colors, adaptive font color, tooltips, and right-click "Drill Down to DB Query Store" context menu Modified Files: 5. QuerySessionControl.axaml — Added "QS Overview" button 6. QuerySessionControl.axaml.cs — Added QueryStoreOverview_Click(object?, RoutedEventArgs) handler that opens the overview tab and wires drill-down to open single-DB Query Store tabs * 1. Drill-down with time range: DrillDownRequested now passes a DrillDownEventArgs containing Database, StartUtc, and EndUtc. The session control calls grid.SetInitialTimeRange() before the grid auto-fetches, so the drilled-down Query Store tab starts with the same time range selected in the overview. 2. Progress bar: Added an indeterminate ProgressBar at the top of the overview. It shows during LoadAsync() (all 3 phases) and during RefreshMetricsAndWaitStatsAsync(CancellationToken) (when the slicer range changes), and hides when complete via try/finally. * improve the dashboard * fix issue about waits stats on the QS overview * fix null on WTR in sql * add evg ref line in waitstats chart * 1. Dead code removed — Deleted FetchDatabaseWaitStatsAsync and DatabaseWaitCategoryTimeSlice (no callers). 2. Bare catch blocks fixed — All 4 parallel fetch methods now use when (ex is not OperationCanceledException) or when (!ct.IsCancellationRequested) so cancellation propagates correctly. 3. Permission errors surfaced — Added QueryStoreState.Error enum value and ErrorMessage property to DatabaseQueryStoreState. The donut now shows a red "Error" segment, and clicking it lists databases with their error messages. 4. _cts leak fixed — Added _cts?.Dispose() before every reassignment in LoadAsync() and OnSlicerRangeChanged(object?, TimeRangeChangedEventArgs), plus a DetachedFromVisualTree handler that cancels and disposes on control teardown. 5. SizeChanged race fixed — DrawWaitStatsChart() now returns early if _dbColorMap is empty, preventing all bars from being bucketed into "Others" when a resize fires before DrawBarCards() has run. 6. Misnamed field renamed — WaitRatio → WaitAmountHours on DatabaseWaitAmountTimeSlice and all references in the service and control. --------- Co-authored-by: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> --- .../Controls/QuerySessionControl.axaml | 5 + .../Controls/QuerySessionControl.axaml.cs | 155 ++++ .../Controls/QueryStoreGridControl.axaml.cs | 10 + .../Controls/QueryStoreOverviewControl.axaml | 62 ++ .../QueryStoreOverviewControl.axaml.cs | 814 ++++++++++++++++++ .../Models/QueryStoreOverviewModels.cs | 76 ++ .../Services/QueryStoreOverviewService.cs | 369 ++++++++ 7 files changed, 1491 insertions(+) create mode 100644 src/PlanViewer.App/Controls/QueryStoreOverviewControl.axaml create mode 100644 src/PlanViewer.App/Controls/QueryStoreOverviewControl.axaml.cs create mode 100644 src/PlanViewer.Core/Models/QueryStoreOverviewModels.cs create mode 100644 src/PlanViewer.Core/Services/QueryStoreOverviewService.cs diff --git a/src/PlanViewer.App/Controls/QuerySessionControl.axaml b/src/PlanViewer.App/Controls/QuerySessionControl.axaml index 24d90f5..a6fa92c 100644 --- a/src/PlanViewer.App/Controls/QuerySessionControl.axaml +++ b/src/PlanViewer.App/Controls/QuerySessionControl.axaml @@ -57,6 +57,11 @@ Height="28" Padding="8,0" FontSize="12" Theme="{StaticResource AppButton}" ToolTip.Tip="Analyze top queries from Query Store"/> + diff --git a/src/PlanViewer.Ssms/AppLauncher.cs b/src/PlanViewer.Ssms/AppLauncher.cs index 1fc89c6..13aa4fd 100644 --- a/src/PlanViewer.Ssms/AppLauncher.cs +++ b/src/PlanViewer.Ssms/AppLauncher.cs @@ -7,7 +7,7 @@ namespace PlanViewer.Ssms { /// - /// Finds and launches SQL Performance Studio with a plan file. + /// Finds and launches Performance Studio with a plan file. /// If the app is already running, sends the file via named pipe /// so it opens as a new tab instead of a new window. /// @@ -39,7 +39,7 @@ public static string SavePlanToTemp(string planXml) } /// - /// Opens the file in SQL Performance Studio. If the app is already running, + /// Opens the file in Performance Studio. If the app is already running, /// sends the file path via named pipe (opens as a new tab). Otherwise /// launches a new instance. /// Returns true if the file was sent or launched successfully. @@ -172,6 +172,9 @@ private static string FindInCommonLocations() Path.Combine(localAppData, "Programs", "plan-b", ExeName), Path.Combine(programFiles, "DarlingData", "SQLPerformanceStudio", ExeName), Path.Combine(programFilesX86, "DarlingData", "SQLPerformanceStudio", ExeName), + Path.Combine(programFiles, "Performance Studio", ExeName), + Path.Combine(programFilesX86, "Performance Studio", ExeName), + // Legacy install paths — kept so existing installs still launch: Path.Combine(programFiles, "SQL Performance Studio", ExeName), Path.Combine(programFilesX86, "SQL Performance Studio", ExeName), }; @@ -194,8 +197,8 @@ public static string BrowseForApp() { using (var dialog = new System.Windows.Forms.OpenFileDialog()) { - dialog.Title = "Locate SQL Performance Studio"; - dialog.Filter = "SQL Performance Studio|PlanViewer.App.exe|All executables|*.exe"; + dialog.Title = "Locate Performance Studio"; + dialog.Filter = "Performance Studio|PlanViewer.App.exe|All executables|*.exe"; dialog.FileName = ExeName; if (dialog.ShowDialog() == System.Windows.Forms.DialogResult.OK diff --git a/src/PlanViewer.Ssms/Properties/AssemblyInfo.cs b/src/PlanViewer.Ssms/Properties/AssemblyInfo.cs index 621a97f..92bf3f6 100644 --- a/src/PlanViewer.Ssms/Properties/AssemblyInfo.cs +++ b/src/PlanViewer.Ssms/Properties/AssemblyInfo.cs @@ -2,10 +2,10 @@ using System.Runtime.InteropServices; [assembly: AssemblyTitle("PlanViewer.Ssms")] -[assembly: AssemblyDescription("SSMS extension to open execution plans in SQL Performance Studio")] +[assembly: AssemblyDescription("SSMS extension to open execution plans in Performance Studio")] [assembly: AssemblyCompany("Darling Data")] -[assembly: AssemblyProduct("SQL Performance Studio for SSMS")] +[assembly: AssemblyProduct("Performance Studio for SSMS")] [assembly: AssemblyCopyright("Copyright Darling Data 2026")] [assembly: ComVisible(false)] -[assembly: AssemblyVersion("1.0.0.0")] -[assembly: AssemblyFileVersion("1.0.0.0")] +[assembly: AssemblyVersion("1.10.0.0")] +[assembly: AssemblyFileVersion("1.10.0.0")] diff --git a/src/PlanViewer.Ssms/VSPackage.resx b/src/PlanViewer.Ssms/VSPackage.resx index f383206..4624f90 100644 --- a/src/PlanViewer.Ssms/VSPackage.resx +++ b/src/PlanViewer.Ssms/VSPackage.resx @@ -58,6 +58,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - SQL Performance Studio for SSMS + Performance Studio for SSMS diff --git a/src/PlanViewer.Ssms/install.cmd b/src/PlanViewer.Ssms/install.cmd index 3adfe9e..1278afa 100644 --- a/src/PlanViewer.Ssms/install.cmd +++ b/src/PlanViewer.Ssms/install.cmd @@ -1,5 +1,5 @@ @echo off -REM Installs the SQL Performance Studio SSMS extension. +REM Installs the Performance Studio SSMS extension. REM For SSMS 22, this must be run from an elevated (admin) command prompt. setlocal @@ -14,7 +14,7 @@ if not exist "%VSIX%" ( exit /b 1 ) -echo Installing SQL Performance Studio SSMS extension... +echo Installing Performance Studio SSMS extension... echo VSIX: %VSIX% echo. diff --git a/src/PlanViewer.Ssms/source.extension.vsixmanifest b/src/PlanViewer.Ssms/source.extension.vsixmanifest index a07a5ff..cacb0a1 100644 --- a/src/PlanViewer.Ssms/source.extension.vsixmanifest +++ b/src/PlanViewer.Ssms/source.extension.vsixmanifest @@ -3,11 +3,11 @@ xmlns:d="http://schemas.microsoft.com/developer/vsx-schema-design/2011"> - SQL Performance Studio for SSMS - Adds "Open in SQL Performance Studio" to the execution plan right-click menu in SSMS. Extracts the plan XML and opens it in SQL Performance Studio for advanced analysis. + 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. https://github.com/erikdarlingdata/PerformanceStudio LICENSE SQL Server, Execution Plan, Performance, SSMS diff --git a/src/PlanViewer.Web/PlanViewer.Web.csproj b/src/PlanViewer.Web/PlanViewer.Web.csproj index 9e23c72..606265c 100644 --- a/src/PlanViewer.Web/PlanViewer.Web.csproj +++ b/src/PlanViewer.Web/PlanViewer.Web.csproj @@ -5,11 +5,6 @@ enable enable PlanViewer.Web - 1.7.3 - Erik Darling - Darling Data LLC - SQL Performance Studio - Copyright (c) 2026 Erik Darling, Darling Data LLC true From 358042693d416ef62b0b8b692921ce02661c67aa Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Sat, 9 May 2026 11:29:49 -0500 Subject: [PATCH 04/27] Silence AVLN3001 warnings via Directory.Build.props NoWarn (#316) All flagged controls (QuerySessionControl, QueryStoreGridControl, QueryStoreOverviewControl, ConnectionDialog, QueryStoreHistoryWindow) take typed constructor dependencies and are instantiated directly in code-behind. They are never loaded via AvaloniaXamlLoader.Load() and the Avalonia IDE previewer isn't used in this project, so the warning flags scenarios that aren't load-bearing. Co-authored-by: Claude Opus 4.7 (1M context) --- src/Directory.Build.props | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 4bf12e1..0c5debe 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -21,4 +21,15 @@ Performance Studio Copyright (c) 2026 Erik Darling, Darling Data LLC + + + + $(NoWarn);AVLN3001 + From 6a4338d177ce7b088751821a6d825d6e5936b919 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Sat, 9 May 2026 11:30:41 -0500 Subject: [PATCH 05/27] Speed up CI with NuGet cache + solution-wide build (#317) - setup-dotnet@v5 with cache: true persists ~/.nuget/packages between runs, keyed on **/*.csproj. Cold restore (~90s) becomes a tar extract on cache hit. - Replace five sequential dotnet restore + five sequential dotnet build invocations with one each against PlanViewer.sln. MSBuild builds the dependency graph in parallel internally, and we save four MSBuild startup cycles. - Tests still build via the solution build, then run with --no-build. PlanViewer.Ssms and PlanViewer.Ssms.Installer are intentionally not in PlanViewer.sln, so they remain excluded from CI as before. Co-authored-by: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 22 +++++++--------------- 1 file changed, 7 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 26b6f82..1f7b77c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,25 +17,17 @@ jobs: uses: actions/setup-dotnet@v5 with: dotnet-version: 8.0.x + cache: true + cache-dependency-path: '**/*.csproj' - name: Install WASM workload run: dotnet workload install wasm-tools - - name: Restore dependencies - run: | - dotnet restore src/PlanViewer.Core/PlanViewer.Core.csproj - dotnet restore src/PlanViewer.App/PlanViewer.App.csproj - dotnet restore src/PlanViewer.Cli/PlanViewer.Cli.csproj - dotnet restore src/PlanViewer.Web/PlanViewer.Web.csproj - dotnet restore tests/PlanViewer.Core.Tests/PlanViewer.Core.Tests.csproj - - - name: Build all projects - run: | - dotnet build src/PlanViewer.Core/PlanViewer.Core.csproj -c Release --no-restore - dotnet build src/PlanViewer.App/PlanViewer.App.csproj -c Release --no-restore - dotnet build src/PlanViewer.Cli/PlanViewer.Cli.csproj -c Release --no-restore - dotnet build src/PlanViewer.Web/PlanViewer.Web.csproj -c Release --no-restore - dotnet build tests/PlanViewer.Core.Tests/PlanViewer.Core.Tests.csproj -c Release --no-restore + - name: Restore solution + run: dotnet restore PlanViewer.sln + + - name: Build solution + run: dotnet build PlanViewer.sln -c Release --no-restore - name: Run tests run: dotnet test tests/PlanViewer.Core.Tests/PlanViewer.Core.Tests.csproj -c Release --no-build --verbosity normal From 4e36226751611f0e01e44f083c97c5fd8afd63c4 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Sat, 9 May 2026 11:55:21 -0500 Subject: [PATCH 06/27] Try ubuntu-latest for CI (experiment) (#319) Linux runners are ~2x faster than windows-latest on GitHub-hosted. None of the projects in PlanViewer.sln are Windows-only: - PlanViewer.Core, .App, .Cli are net8.0 cross-platform - PlanViewer.Web is Blazor WASM, builds on Linux with wasm-tools - PlanViewer.Core.Tests is xunit on net8.0 - PlanViewer.App already references SkiaSharp.NativeAssets.Linux PlanViewer.Ssms (legacy net472 + VSSDK) is not in the solution and is not built in CI; it stays Windows-only via its own workflow if one exists. If anything fails on Linux that's not flaggable as a small fix, revert this PR and we keep the cache + solution-build wins from #317. Co-authored-by: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1f7b77c..8f29ef7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,7 +8,7 @@ on: jobs: build-and-test: - runs-on: windows-latest + runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 From c4a326f13b1d224340028b3343ed14e67d2b03cd Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Sat, 9 May 2026 11:57:38 -0500 Subject: [PATCH 07/27] Skip CI on non-build changes via paths-ignore (#318) PR-only filter (push to main keeps building unconditionally). Skips the workflow when every changed file matches one of: - Documentation: **.md, LICENSE, CITATION.cff, llms.txt - Repo config: .gitignore, .gitattributes, .github/ISSUE_TEMPLATE/** - Non-CI artifacts: docs/, screenshots/, server/ - Projects intentionally excluded from PlanViewer.sln: src/PlanViewer.Ssms/** src/PlanViewer.Ssms.Installer/** If any changed file falls outside the ignore list, the workflow runs as before. Mirrors the path filter pattern already in deploy-web.yml. Co-authored-by: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8f29ef7..6150df1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,19 @@ on: branches: [main] pull_request: branches: [main, dev] + paths-ignore: + - '**.md' + - 'LICENSE' + - '.gitattributes' + - '.gitignore' + - 'CITATION.cff' + - 'llms.txt' + - '.github/ISSUE_TEMPLATE/**' + - 'docs/**' + - 'screenshots/**' + - 'server/**' + - 'src/PlanViewer.Ssms/**' + - 'src/PlanViewer.Ssms.Installer/**' jobs: build-and-test: From 801f2578ac9cdbc8186d9d408f48a5a0e490764e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A1udio=20Silva?= Date: Sun, 10 May 2026 19:22:52 +0100 Subject: [PATCH 08/27] feature - Improve navigation (#323) * Allow navigate between open tabs with keyboard #322 * Allow double-click to expand grouped rows #322 * Close tab when doing mouse wheel click #322 * improve implementation --- .../Controls/QueryStoreGridControl.axaml | 1 + .../Controls/QueryStoreGridControl.axaml.cs | 18 ++++++++++++ src/PlanViewer.App/MainWindow.axaml.cs | 28 +++++++++++++++++++ 3 files changed, 47 insertions(+) diff --git a/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml b/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml index 29555d1..29e8d0f 100644 --- a/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml +++ b/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml @@ -133,6 +133,7 @@ Background="{DynamicResource BackgroundDarkBrush}" BorderThickness="0" Sorting="ResultsGrid_Sorting" + DoubleTapped="ResultsGrid_DoubleTapped" ScrollViewer.HorizontalScrollBarVisibility="Auto"> diff --git a/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml.cs b/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml.cs index edbd75b..bb30ea2 100644 --- a/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml.cs +++ b/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml.cs @@ -5,9 +5,12 @@ using System.Linq; using System.Runtime.CompilerServices; using System.Threading; +using Avalonia; using Avalonia.Controls; using Avalonia.Controls.Primitives; +using Avalonia.Input; using Avalonia.Interactivity; +using Avalonia.VisualTree; using Avalonia.Media; using PlanViewer.Core.Interfaces; using PlanViewer.Core.Models; @@ -758,6 +761,21 @@ private void ExpandRow_Click(object? sender, RoutedEventArgs e) { if (sender is not Button btn) return; if (btn.DataContext is not QueryStoreRow row) return; + ToggleRowExpansion(row); + } + + private void ResultsGrid_DoubleTapped(object? sender, TappedEventArgs e) + { + if (e.Source is not Visual v) return; + if (v.FindAncestorOfType public bool IsTopRepresentative { get; set; } + public string ExecutionTypeDesc { get; set; } = ""; } /// diff --git a/src/PlanViewer.Core/Models/QueryStoreHistoryRow.cs b/src/PlanViewer.Core/Models/QueryStoreHistoryRow.cs index b275acf..e89b5b7 100644 --- a/src/PlanViewer.Core/Models/QueryStoreHistoryRow.cs +++ b/src/PlanViewer.Core/Models/QueryStoreHistoryRow.cs @@ -27,6 +27,7 @@ public class QueryStoreHistoryRow public int MinDop { get; set; } public int MaxDop { get; set; } public DateTime? LastExecutionUtc { get; set; } + public string ExecutionTypeDesc { get; set; } = ""; // Display-formatted properties (2 decimal places) public string AvgDurationMsDisplay => AvgDurationMs.ToString("N2"); diff --git a/src/PlanViewer.Core/Models/QueryStorePlan.cs b/src/PlanViewer.Core/Models/QueryStorePlan.cs index d0369c3..f5ef750 100644 --- a/src/PlanViewer.Core/Models/QueryStorePlan.cs +++ b/src/PlanViewer.Core/Models/QueryStorePlan.cs @@ -13,6 +13,11 @@ public class QueryStoreFilter public string? QueryHash { get; set; } public string? QueryPlanHash { get; set; } public string? ModuleName { get; set; } + /// + /// One or more execution_type_desc values to filter by. + /// Single value → equality predicate; multiple values (e.g. "Aborted","Exception" for "Failed") → IN predicate. + /// + public string[]? ExecutionTypeDescs { get; set; } } public class QueryStorePlan @@ -22,6 +27,7 @@ public class QueryStorePlan public string QueryHash { get; set; } = ""; public string QueryPlanHash { get; set; } = ""; public string ModuleName { get; set; } = ""; + public string ExecutionTypeDesc { get; set; } = ""; public string QueryText { get; set; } = ""; public string PlanXml { get; set; } = ""; diff --git a/src/PlanViewer.Core/Services/QueryStoreService.cs b/src/PlanViewer.Core/Services/QueryStoreService.cs index 6ee0ad1..137c084 100644 --- a/src/PlanViewer.Core/Services/QueryStoreService.cs +++ b/src/PlanViewer.Core/Services/QueryStoreService.cs @@ -138,6 +138,16 @@ public static async Task> FetchTopPlansAsync( var phase3QueryJoin = needsQueryJoin ? " JOIN sys.query_store_query AS q ON p.query_id = q.query_id\n" : ""; + var phase2ExecutionTypeClause = ""; + if (filter?.ExecutionTypeDescs?.Length > 0) + { + var etParamNames = filter.ExecutionTypeDescs + .Select((_, i) => $"@executionType{i}") + .ToList(); + phase2ExecutionTypeClause = $"\nAND rs.execution_type_desc IN ({string.Join(", ", etParamNames)})"; + for (var i = 0; i < filter.ExecutionTypeDescs.Length; i++) + parameters.Add(new SqlParameter($"@executionType{i}", filter.ExecutionTypeDescs[i])); + } // Time-range filter: always filter on interval start_time (indexed). // The hoursBack fallback also uses interval start_time instead of @@ -199,7 +209,8 @@ FROM sys.query_store_runtime_stats_interval AS rsi total_physical_reads float NOT NULL, total_memory_pages float NOT NULL, total_executions bigint NOT NULL, - last_execution_time datetimeoffset NOT NULL + last_execution_time datetimeoffset NOT NULL, + execution_type_desc nvarchar(60) NOT NULL ); INSERT INTO #plan_stats SELECT @@ -211,14 +222,20 @@ INSERT INTO #plan_stats SUM(rs.avg_physical_io_reads * rs.count_executions), SUM(rs.avg_query_max_used_memory * rs.count_executions), SUM(rs.count_executions), - MAX(rs.last_execution_time) + MAX(rs.last_execution_time), + -- Pick execution_type_desc from the most-recently-executed interval to avoid + -- alphabetical bias: MAX would choose 'Regular' over 'Aborted'. + RTRIM(CAST(SUBSTRING(MAX( + CONVERT(char(27), CAST(rs.last_execution_time AS datetime2(7)), 121) + + CAST(ISNULL(rs.execution_type_desc, '') AS char(60)) + ), 28, 60) AS nvarchar(60))) FROM sys.query_store_runtime_stats AS rs WHERE EXISTS ( SELECT 1 FROM #intervals AS i WHERE i.runtime_stats_interval_id = rs.runtime_stats_interval_id -) +){phase2ExecutionTypeClause} GROUP BY rs.plan_id OPTION (RECOMPILE); @@ -236,6 +253,7 @@ WITH ranked AS ( ps.total_memory_pages, ps.total_executions, ps.last_execution_time, + ps.execution_type_desc, CASE WHEN ps.total_executions > 0 THEN ps.total_cpu_us / ps.total_executions ELSE 0 END AS avg_cpu_us, CASE WHEN ps.total_executions > 0 @@ -269,7 +287,8 @@ SELECT TOP ({topN}) CAST(r.total_writes AS bigint) AS total_writes, CAST(r.total_physical_reads AS bigint) AS total_physical_reads, CAST(r.total_memory_pages AS bigint) AS total_memory_pages, - r.last_execution_time + r.last_execution_time, + r.execution_type_desc INTO #top_plans FROM ranked AS r WHERE 1 = 1 {rnClause} @@ -301,7 +320,8 @@ FROM ranked AS r WHEN q.object_id <> 0 THEN OBJECT_SCHEMA_NAME(q.object_id) + N'.' + OBJECT_NAME(q.object_id) ELSE N'' - END + END, + tp.execution_type_desc FROM #top_plans AS tp JOIN sys.query_store_plan AS p ON tp.plan_id = p.plan_id JOIN sys.query_store_query AS q ON p.query_id = q.query_id @@ -346,6 +366,7 @@ ELSE N'' QueryHash = reader.IsDBNull(18) ? "" : reader.GetString(18), QueryPlanHash = reader.IsDBNull(19) ? "" : reader.GetString(19), ModuleName = reader.IsDBNull(20) ? "" : reader.GetString(20), + ExecutionTypeDesc = reader.IsDBNull(21) ? "" : reader.GetString(21), }); } @@ -392,7 +413,8 @@ THEN SUM(rs.avg_rowcount * rs.count_executions) / SUM(rs.count_executions) SUM(rs.avg_physical_io_reads * rs.count_executions), MIN(rs.min_dop), MAX(rs.max_dop), - MAX(rs.last_execution_time) + MAX(rs.last_execution_time), + MAX(rs.execution_type_desc) FROM sys.query_store_runtime_stats rs JOIN sys.query_store_runtime_stats_interval rsi ON rs.runtime_stats_interval_id = rsi.runtime_stats_interval_id @@ -436,6 +458,7 @@ JOIN sys.query_store_plan p MinDop = (int)reader.GetInt64(16), MaxDop = (int)reader.GetInt64(17), LastExecutionUtc = reader.IsDBNull(18) ? null : ((DateTimeOffset)reader.GetValue(18)).UtcDateTime, + ExecutionTypeDesc = reader.IsDBNull(19) ? "" : reader.GetString(19), }); } @@ -504,7 +527,8 @@ THEN SUM(rs.avg_rowcount * rs.count_executions) / SUM(rs.count_executions) SUM(rs.avg_physical_io_reads * rs.count_executions), MIN(rs.min_dop), MAX(rs.max_dop), - MAX(rs.last_execution_time) + MAX(rs.last_execution_time), + MAX(rs.execution_type_desc) FROM sys.query_store_runtime_stats rs JOIN sys.query_store_runtime_stats_interval rsi ON rs.runtime_stats_interval_id = rsi.runtime_stats_interval_id @@ -549,6 +573,7 @@ JOIN sys.query_store_query q MinDop = (int)reader.GetInt64(16), MaxDop = (int)reader.GetInt64(17), LastExecutionUtc = reader.IsDBNull(18) ? null : ((DateTimeOffset)reader.GetValue(18)).UtcDateTime, + ExecutionTypeDesc = reader.IsDBNull(19) ? "" : reader.GetString(19), }); } @@ -619,7 +644,8 @@ THEN SUM(rs.avg_rowcount * rs.count_executions) / SUM(rs.count_executions) MIN(rs.min_dop), MAX(rs.max_dop), MAX(rs.last_execution_time), - SUM(rs.avg_query_max_used_memory * rs.count_executions) + SUM(rs.avg_query_max_used_memory * rs.count_executions), + MAX(rs.execution_type_desc) FROM sys.query_store_runtime_stats rs JOIN sys.query_store_runtime_stats_interval rsi ON rs.runtime_stats_interval_id = rsi.runtime_stats_interval_id @@ -664,6 +690,7 @@ JOIN sys.query_store_query q MaxDop = (int)reader.GetInt64(16), LastExecutionUtc = reader.IsDBNull(17) ? null : ((DateTimeOffset)reader.GetValue(17)).UtcDateTime, TotalMemoryMb = reader.GetDouble(18), + ExecutionTypeDesc = reader.IsDBNull(19) ? "" : reader.GetString(19), }); } @@ -1004,6 +1031,16 @@ public static async Task FetchGroupedByQueryHashAsync( parameters.Add(new SqlParameter("@filterModule", moduleVal)); } var filterSql = filterClauses.Count > 0 ? "\n" + string.Join("\n", filterClauses) : ""; + var phase2ExecutionTypeClause = ""; + if (filter?.ExecutionTypeDescs?.Length > 0) + { + var etParamNames = filter.ExecutionTypeDescs + .Select((_, i) => $"@executionType{i}") + .ToList(); + phase2ExecutionTypeClause = $"\nAND rs.execution_type_desc IN ({string.Join(", ", etParamNames)})"; + for (var i = 0; i < filter.ExecutionTypeDescs.Length; i++) + parameters.Add(new SqlParameter($"@executionType{i}", filter.ExecutionTypeDescs[i])); + } var sql = $@" SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; @@ -1028,7 +1065,8 @@ FROM sys.query_store_runtime_stats_interval AS rsi total_physical_reads float NOT NULL, total_memory_pages float NOT NULL, total_executions bigint NOT NULL, - last_execution_time datetimeoffset NOT NULL + last_execution_time datetimeoffset NOT NULL, + execution_type_desc nvarchar(60) NOT NULL ); INSERT INTO #plan_stats SELECT @@ -1040,9 +1078,13 @@ INSERT INTO #plan_stats SUM(rs.avg_physical_io_reads * rs.count_executions), SUM(rs.avg_query_max_used_memory * rs.count_executions), SUM(rs.count_executions), - MAX(rs.last_execution_time) + MAX(rs.last_execution_time), + RTRIM(CAST(SUBSTRING(MAX( + CONVERT(char(27), CAST(rs.last_execution_time AS datetime2(7)), 121) + + CAST(ISNULL(rs.execution_type_desc, '') AS char(60)) + ), 28, 60) AS nvarchar(60))) FROM sys.query_store_runtime_stats AS rs -WHERE EXISTS (SELECT 1 FROM #intervals AS i WHERE i.runtime_stats_interval_id = rs.runtime_stats_interval_id) +WHERE EXISTS (SELECT 1 FROM #intervals AS i WHERE i.runtime_stats_interval_id = rs.runtime_stats_interval_id){phase2ExecutionTypeClause} GROUP BY rs.plan_id OPTION (RECOMPILE); @@ -1080,6 +1122,7 @@ THEN OBJECT_SCHEMA_NAME(q.object_id) + N'.' + OBJECT_NAME(q.object_id) SUM(ps.total_memory_pages) AS total_memory_pages, SUM(ps.total_executions) AS total_executions, MAX(ps.last_execution_time) AS last_execution_time, + MAX(ps.execution_type_desc) AS execution_type_desc, ROW_NUMBER() OVER (PARTITION BY q.query_hash ORDER BY SUM(ps.{metricCol}) DESC) AS rnum FROM #plan_stats ps JOIN sys.query_store_plan p ON ps.plan_id = p.plan_id @@ -1098,7 +1141,8 @@ ELSE N'' END CAST(total_physical_reads AS bigint) AS total_physical_reads, CAST(total_memory_pages AS bigint) AS total_memory_pages, total_executions, - last_execution_time + last_execution_time, + execution_type_desc INTO #plan_hash_rows FROM ph WHERE rnum <= 5; @@ -1121,6 +1165,7 @@ THEN OBJECT_SCHEMA_NAME(q.object_id) + N'.' + OBJECT_NAME(q.object_id) CAST(ps.total_memory_pages AS bigint) AS total_memory_pages, ps.total_executions, ps.last_execution_time, + ps.execution_type_desc, ROW_NUMBER() OVER (PARTITION BY q.query_hash, p.query_plan_hash ORDER BY ps.{metricCol} DESC) AS rn_top, ROW_NUMBER() OVER (PARTITION BY q.query_hash, p.query_plan_hash ORDER BY ps.{metricCol} ASC) AS rn_bottom FROM #plan_stats ps @@ -1152,7 +1197,8 @@ FROM ranked r.total_memory_pages, r.total_executions, r.last_execution_time, -CASE WHEN r.rn_top = 1 THEN 1 ELSE 0 END AS is_top +CASE WHEN r.rn_top = 1 THEN 1 ELSE 0 END AS is_top, +r.execution_type_desc FROM #ranked_light r JOIN sys.query_store_query_text qt ON r.query_text_id = qt.query_text_id JOIN sys.query_store_plan p ON r.plan_id = p.plan_id; @@ -1189,6 +1235,7 @@ FROM ranked CountExecutions = reader.GetInt64(13), LastExecutedUtc = ((DateTimeOffset)reader.GetValue(14)).UtcDateTime, IsTopRepresentative = reader.GetInt32(15) == 1, + ExecutionTypeDesc = reader.IsDBNull(16) ? "" : reader.GetString(16), }); } @@ -1210,6 +1257,7 @@ FROM ranked TotalMemoryGrantPages = reader.GetInt64(8), CountExecutions = reader.GetInt64(9), LastExecutedUtc = ((DateTimeOffset)reader.GetValue(10)).UtcDateTime, + ExecutionTypeDesc = reader.IsDBNull(11) ? "" : reader.GetString(11), }); } } @@ -1263,6 +1311,16 @@ public static async Task FetchGroupedByModuleAsync( parameters.Add(new SqlParameter("@filterQueryHash", filter.QueryHash.Trim())); } var filterSql = filterClauses.Count > 0 ? "\n" + string.Join("\n", filterClauses) : ""; + var phase2ExecutionTypeClause = ""; + if (filter?.ExecutionTypeDescs?.Length > 0) + { + var etParamNames = filter.ExecutionTypeDescs + .Select((_, i) => $"@executionType{i}") + .ToList(); + phase2ExecutionTypeClause = $"\nAND rs.execution_type_desc IN ({string.Join(", ", etParamNames)})"; + for (var i = 0; i < filter.ExecutionTypeDescs.Length; i++) + parameters.Add(new SqlParameter($"@executionType{i}", filter.ExecutionTypeDescs[i])); + } var sql = $@" SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; @@ -1287,7 +1345,8 @@ FROM sys.query_store_runtime_stats_interval AS rsi total_physical_reads float NOT NULL, total_memory_pages float NOT NULL, total_executions bigint NOT NULL, - last_execution_time datetimeoffset NOT NULL + last_execution_time datetimeoffset NOT NULL, + execution_type_desc nvarchar(60) NOT NULL ); INSERT INTO #plan_stats SELECT @@ -1299,9 +1358,13 @@ INSERT INTO #plan_stats SUM(rs.avg_physical_io_reads * rs.count_executions), SUM(rs.avg_query_max_used_memory * rs.count_executions), SUM(rs.count_executions), - MAX(rs.last_execution_time) + MAX(rs.last_execution_time), + RTRIM(CAST(SUBSTRING(MAX( + CONVERT(char(27), CAST(rs.last_execution_time AS datetime2(7)), 121) + + CAST(ISNULL(rs.execution_type_desc, '') AS char(60)) + ), 28, 60) AS nvarchar(60))) FROM sys.query_store_runtime_stats AS rs -WHERE EXISTS (SELECT 1 FROM #intervals AS i WHERE i.runtime_stats_interval_id = rs.runtime_stats_interval_id) +WHERE EXISTS (SELECT 1 FROM #intervals AS i WHERE i.runtime_stats_interval_id = rs.runtime_stats_interval_id){phase2ExecutionTypeClause} GROUP BY rs.plan_id OPTION (RECOMPILE); @@ -1342,6 +1405,7 @@ THEN OBJECT_SCHEMA_NAME(q.object_id) + N'.' + OBJECT_NAME(q.object_id) SUM(ps.total_memory_pages) AS total_memory_pages, SUM(ps.total_executions) AS total_executions, MAX(ps.last_execution_time) AS last_execution_time, + MAX(ps.execution_type_desc) AS execution_type_desc, ROW_NUMBER() OVER (PARTITION BY CASE WHEN q.object_id <> 0 THEN OBJECT_SCHEMA_NAME(q.object_id) + N'.' + OBJECT_NAME(q.object_id) @@ -1367,7 +1431,8 @@ THEN OBJECT_SCHEMA_NAME(q.object_id) + N'.' + OBJECT_NAME(q.object_id) CAST(total_physical_reads AS bigint) AS total_physical_reads, CAST(total_memory_pages AS bigint) AS total_memory_pages, total_executions, - last_execution_time + last_execution_time, + execution_type_desc INTO #qhash_rows FROM qh WHERE rnum <= 5; @@ -1390,6 +1455,7 @@ THEN OBJECT_SCHEMA_NAME(q.object_id) + N'.' + OBJECT_NAME(q.object_id) CAST(ps.total_memory_pages AS bigint) AS total_memory_pages, ps.total_executions, ps.last_execution_time, + ps.execution_type_desc, ROW_NUMBER() OVER (PARTITION BY CASE WHEN q.object_id <> 0 THEN OBJECT_SCHEMA_NAME(q.object_id) + N'.' + OBJECT_NAME(q.object_id) @@ -1433,7 +1499,8 @@ FROM ranked r.total_memory_pages, r.total_executions, r.last_execution_time, - CASE WHEN r.rn_top = 1 THEN 1 ELSE 0 END AS is_top + CASE WHEN r.rn_top = 1 THEN 1 ELSE 0 END AS is_top, + r.execution_type_desc FROM #ranked_light r JOIN sys.query_store_query_text qt ON r.query_text_id = qt.query_text_id JOIN sys.query_store_plan p ON r.plan_id = p.plan_id; @@ -1470,6 +1537,7 @@ FROM ranked CountExecutions = reader.GetInt64(13), LastExecutedUtc = ((DateTimeOffset)reader.GetValue(14)).UtcDateTime, IsTopRepresentative = reader.GetInt32(15) == 1, + ExecutionTypeDesc = reader.IsDBNull(16) ? "" : reader.GetString(16), }); } @@ -1490,6 +1558,7 @@ FROM ranked TotalMemoryGrantPages = reader.GetInt64(7), CountExecutions = reader.GetInt64(8), LastExecutedUtc = ((DateTimeOffset)reader.GetValue(9)).UtcDateTime, + ExecutionTypeDesc = reader.IsDBNull(10) ? "" : reader.GetString(10), }); } } From f6d322f34219adfd31e960903587f79fbf75a533 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Sun, 10 May 2026 14:27:43 -0500 Subject: [PATCH 10/27] Remove dead FetchHistoryAsync and FetchHistoryByHashAsync methods (#324) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both methods had no callers — only FetchAggregateHistoryAsync is wired up to QueryStoreHistoryWindow. Cleanup follows the execution_type_desc work in #321. Co-authored-by: Claude Opus 4.7 (1M context) --- .../Services/QueryStoreService.cs | 207 ------------------ 1 file changed, 207 deletions(-) diff --git a/src/PlanViewer.Core/Services/QueryStoreService.cs b/src/PlanViewer.Core/Services/QueryStoreService.cs index 137c084..7bcb496 100644 --- a/src/PlanViewer.Core/Services/QueryStoreService.cs +++ b/src/PlanViewer.Core/Services/QueryStoreService.cs @@ -373,213 +373,6 @@ ELSE N'' return plans; } - public static async Task> FetchHistoryAsync( - string connectionString, long queryId, int hoursBack = 24, - CancellationToken ct = default) - { - const string sql = @" -SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; - -SELECT - p.plan_id, - CONVERT(varchar(18), MAX(p.query_plan_hash), 1), - rsi.start_time, - SUM(rs.count_executions), - CASE WHEN SUM(rs.count_executions) > 0 - THEN SUM(rs.avg_duration * rs.count_executions) / SUM(rs.count_executions) / 1000.0 - ELSE 0 END, - CASE WHEN SUM(rs.count_executions) > 0 - THEN SUM(rs.avg_cpu_time * rs.count_executions) / SUM(rs.count_executions) / 1000.0 - ELSE 0 END, - CASE WHEN SUM(rs.count_executions) > 0 - THEN SUM(rs.avg_logical_io_reads * rs.count_executions) / SUM(rs.count_executions) - ELSE 0 END, - CASE WHEN SUM(rs.count_executions) > 0 - THEN SUM(rs.avg_logical_io_writes * rs.count_executions) / SUM(rs.count_executions) - ELSE 0 END, - CASE WHEN SUM(rs.count_executions) > 0 - THEN SUM(rs.avg_physical_io_reads * rs.count_executions) / SUM(rs.count_executions) - ELSE 0 END, - CASE WHEN SUM(rs.count_executions) > 0 - THEN SUM(rs.avg_query_max_used_memory * rs.count_executions) / SUM(rs.count_executions) * 8.0 / 1024.0 - ELSE 0 END, - CASE WHEN SUM(rs.count_executions) > 0 - THEN SUM(rs.avg_rowcount * rs.count_executions) / SUM(rs.count_executions) - ELSE 0 END, - SUM(rs.avg_duration * rs.count_executions) / 1000.0, - SUM(rs.avg_cpu_time * rs.count_executions) / 1000.0, - SUM(rs.avg_logical_io_reads * rs.count_executions), - SUM(rs.avg_logical_io_writes * rs.count_executions), - SUM(rs.avg_physical_io_reads * rs.count_executions), - MIN(rs.min_dop), - MAX(rs.max_dop), - MAX(rs.last_execution_time), - MAX(rs.execution_type_desc) -FROM sys.query_store_runtime_stats rs -JOIN sys.query_store_runtime_stats_interval rsi - ON rs.runtime_stats_interval_id = rsi.runtime_stats_interval_id -JOIN sys.query_store_plan p - ON rs.plan_id = p.plan_id -WHERE p.query_id = @queryId -AND rsi.start_time >= DATEADD(HOUR, -@hoursBack, GETUTCDATE()) -AND rs.first_execution_time >= DATEADD(HOUR, -@hoursBack, GETUTCDATE()) --performance: filter runtime_stats by time directly -GROUP BY p.plan_id, rsi.start_time -ORDER BY rsi.start_time, p.plan_id;"; - - var rows = new List(); - - await using var conn = new SqlConnection(connectionString); - await conn.OpenAsync(ct); - await using var cmd = new SqlCommand(sql, conn) { CommandTimeout = 120 }; - cmd.Parameters.Add(new SqlParameter("@queryId", queryId)); - cmd.Parameters.Add(new SqlParameter("@hoursBack", hoursBack)); - await using var reader = await cmd.ExecuteReaderAsync(ct); - - while (await reader.ReadAsync(ct)) - { - rows.Add(new QueryStoreHistoryRow - { - PlanId = reader.GetInt64(0), - QueryPlanHash = reader.IsDBNull(1) ? "" : reader.GetString(1), - IntervalStartUtc = ((DateTimeOffset)reader.GetValue(2)).UtcDateTime, - CountExecutions = reader.GetInt64(3), - AvgDurationMs = reader.GetDouble(4), - AvgCpuMs = reader.GetDouble(5), - AvgLogicalReads = reader.GetDouble(6), - AvgLogicalWrites = reader.GetDouble(7), - AvgPhysicalReads = reader.GetDouble(8), - AvgMemoryMb = reader.GetDouble(9), - AvgRowcount = reader.GetDouble(10), - TotalDurationMs = reader.GetDouble(11), - TotalCpuMs = reader.GetDouble(12), - TotalLogicalReads = reader.GetDouble(13), - TotalLogicalWrites = reader.GetDouble(14), - TotalPhysicalReads = reader.GetDouble(15), - MinDop = (int)reader.GetInt64(16), - MaxDop = (int)reader.GetInt64(17), - LastExecutionUtc = reader.IsDBNull(18) ? null : ((DateTimeOffset)reader.GetValue(18)).UtcDateTime, - ExecutionTypeDesc = reader.IsDBNull(19) ? "" : reader.GetString(19), - }); - } - - return rows; - } - - /// - /// Fetches interval-level history rows for all queries sharing the given query_hash. - /// When / are provided they define the - /// time window (slicer range); otherwise falls back to . - /// - public static async Task> FetchHistoryByHashAsync( - string connectionString, string queryHash, int hoursBack = 24, - CancellationToken ct = default, - DateTime? startUtc = null, DateTime? endUtc = null) - { - var parameters = new List(); - parameters.Add(new SqlParameter("@queryHash", queryHash.Trim())); - - string timeFilter; - if (startUtc.HasValue && endUtc.HasValue) - { - timeFilter = "AND rsi.start_time >= @rangeStart AND rsi.start_time < @rangeEnd"; - parameters.Add(new SqlParameter("@rangeStart", startUtc.Value)); - parameters.Add(new SqlParameter("@rangeEnd", endUtc.Value)); - } - else - { - timeFilter = "AND rsi.start_time >= DATEADD(HOUR, -@hoursBack, GETUTCDATE())"; - parameters.Add(new SqlParameter("@hoursBack", hoursBack)); - } - - var sql = $@" -SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; - -SELECT - p.plan_id, - CONVERT(varchar(18), MAX(p.query_plan_hash), 1), - rsi.start_time, - SUM(rs.count_executions), - CASE WHEN SUM(rs.count_executions) > 0 - THEN SUM(rs.avg_duration * rs.count_executions) / SUM(rs.count_executions) / 1000.0 - ELSE 0 END, - CASE WHEN SUM(rs.count_executions) > 0 - THEN SUM(rs.avg_cpu_time * rs.count_executions) / SUM(rs.count_executions) / 1000.0 - ELSE 0 END, - CASE WHEN SUM(rs.count_executions) > 0 - THEN SUM(rs.avg_logical_io_reads * rs.count_executions) / SUM(rs.count_executions) - ELSE 0 END, - CASE WHEN SUM(rs.count_executions) > 0 - THEN SUM(rs.avg_logical_io_writes * rs.count_executions) / SUM(rs.count_executions) - ELSE 0 END, - CASE WHEN SUM(rs.count_executions) > 0 - THEN SUM(rs.avg_physical_io_reads * rs.count_executions) / SUM(rs.count_executions) - ELSE 0 END, - CASE WHEN SUM(rs.count_executions) > 0 - THEN SUM(rs.avg_query_max_used_memory * rs.count_executions) / SUM(rs.count_executions) * 8.0 / 1024.0 - ELSE 0 END, - CASE WHEN SUM(rs.count_executions) > 0 - THEN SUM(rs.avg_rowcount * rs.count_executions) / SUM(rs.count_executions) - ELSE 0 END, - SUM(rs.avg_duration * rs.count_executions) / 1000.0, - SUM(rs.avg_cpu_time * rs.count_executions) / 1000.0, - SUM(rs.avg_logical_io_reads * rs.count_executions), - SUM(rs.avg_logical_io_writes * rs.count_executions), - SUM(rs.avg_physical_io_reads * rs.count_executions), - MIN(rs.min_dop), - MAX(rs.max_dop), - MAX(rs.last_execution_time), - MAX(rs.execution_type_desc) -FROM sys.query_store_runtime_stats rs -JOIN sys.query_store_runtime_stats_interval rsi - ON rs.runtime_stats_interval_id = rsi.runtime_stats_interval_id -JOIN sys.query_store_plan p - ON rs.plan_id = p.plan_id -JOIN sys.query_store_query q - ON p.query_id = q.query_id -WHERE q.query_hash = CONVERT(binary(8), @queryHash, 1) -{timeFilter} -GROUP BY p.plan_id, rsi.start_time -ORDER BY rsi.start_time, p.plan_id;"; - - var rows = new List(); - - await using var conn = new SqlConnection(connectionString); - await conn.OpenAsync(ct); - await using var cmd = new SqlCommand(sql, conn) { CommandTimeout = 120 }; - foreach (var p in parameters) - cmd.Parameters.Add(p); - await using var reader = await cmd.ExecuteReaderAsync(ct); - - while (await reader.ReadAsync(ct)) - { - rows.Add(new QueryStoreHistoryRow - { - PlanId = reader.GetInt64(0), - QueryPlanHash = reader.IsDBNull(1) ? "" : reader.GetString(1), - IntervalStartUtc = ((DateTimeOffset)reader.GetValue(2)).UtcDateTime, - CountExecutions = reader.GetInt64(3), - AvgDurationMs = reader.GetDouble(4), - AvgCpuMs = reader.GetDouble(5), - AvgLogicalReads = reader.GetDouble(6), - AvgLogicalWrites = reader.GetDouble(7), - AvgPhysicalReads = reader.GetDouble(8), - AvgMemoryMb = reader.GetDouble(9), - AvgRowcount = reader.GetDouble(10), - TotalDurationMs = reader.GetDouble(11), - TotalCpuMs = reader.GetDouble(12), - TotalLogicalReads = reader.GetDouble(13), - TotalLogicalWrites = reader.GetDouble(14), - TotalPhysicalReads = reader.GetDouble(15), - MinDop = (int)reader.GetInt64(16), - MaxDop = (int)reader.GetInt64(17), - LastExecutionUtc = reader.IsDBNull(18) ? null : ((DateTimeOffset)reader.GetValue(18)).UtcDateTime, - ExecutionTypeDesc = reader.IsDBNull(19) ? "" : reader.GetString(19), - }); - } - - return rows; - } - /// /// Fetches interval-level history rows for all queries sharing the given query_hash, /// grouped by query_plan_hash and interval start. From 5d7105e2f7d2b0572c7e9814eb0a58747e169cf5 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Sun, 10 May 2026 14:56:18 -0500 Subject: [PATCH 11/27] Drop redundant TRY_CONVERT(nvarchar(max), p.query_plan) wrapper (#326) sys.query_store_plan.query_plan is already nvarchar(max), so the TRY_CONVERT was a no-op identity cast. It also broke the query on databases at compatibility level <110, where the parser doesn't recognize TRY_CONVERT and reports 'nvarchar' is not a recognized built-in function name. Three call sites in QueryStoreService.cs: FetchTopPlansAsync, FetchGroupedByQueryHashAsync, and FetchGroupedByModuleAsync. Verified against a compat-100 database. Co-authored-by: Claude Opus 4.7 (1M context) --- src/PlanViewer.Core/Services/QueryStoreService.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/PlanViewer.Core/Services/QueryStoreService.cs b/src/PlanViewer.Core/Services/QueryStoreService.cs index 7bcb496..c9158f2 100644 --- a/src/PlanViewer.Core/Services/QueryStoreService.cs +++ b/src/PlanViewer.Core/Services/QueryStoreService.cs @@ -179,8 +179,8 @@ public static async Task> FetchTopPlansAsync( // into #top_plans. Still no nvarchar(max) columns. // // Phase 4: Final SELECT — join only the TOP N winners to query_text, plan - // XML, and query metadata. Uses OUTER APPLY + TRY_CONVERT for - // safe plan XML retrieval. + // XML, and query metadata. query_plan is nvarchar(max) on the + // catalog view, so it's referenced directly without conversion. // // OPTION (RECOMPILE) on aggregation phases prevents parameter sniffing on // date range parameters producing bad plans for different time windows. @@ -299,7 +299,7 @@ FROM ranked AS r tp.query_id, tp.plan_id, qt.query_sql_text, - TRY_CONVERT(nvarchar(max), p.query_plan) AS query_plan, + p.query_plan, tp.avg_cpu_us, tp.avg_duration_us, tp.avg_reads, @@ -980,7 +980,7 @@ FROM ranked r.query_id, r.plan_id, qt.query_sql_text, -TRY_CONVERT(nvarchar(max), p.query_plan) AS plan_xml, +p.query_plan AS plan_xml, r.module_name, r.total_cpu_us, r.total_duration_us, @@ -1283,7 +1283,7 @@ FROM ranked r.query_id, r.plan_id, qt.query_sql_text, - TRY_CONVERT(nvarchar(max), p.query_plan) AS plan_xml, + p.query_plan AS plan_xml, r.total_cpu_us, r.total_duration_us, r.total_reads, From ee400479f3f3ce19e1ac560107d7b61517e533d5 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Sun, 10 May 2026 14:58:07 -0500 Subject: [PATCH 12/27] Expose execution_type_desc filter to CLI and MCP (#325) Adds --execution-type to the CLI query-store command and execution_type to the get_query_store_top MCP tool, accepting regular/aborted/exception/ failed (= aborted + exception). Mirrors the desktop UI filter added in #321. Parsing logic is centralized in QueryStoreFilter.ParseExecutionType. Co-authored-by: Claude Opus 4.7 (1M context) --- src/PlanViewer.App/Mcp/McpQueryStoreTools.cs | 17 +++++++++++-- .../Commands/QueryStoreCommand.cs | 25 +++++++++++++++++-- src/PlanViewer.Core/Models/QueryStorePlan.cs | 20 +++++++++++++++ 3 files changed, 58 insertions(+), 4 deletions(-) diff --git a/src/PlanViewer.App/Mcp/McpQueryStoreTools.cs b/src/PlanViewer.App/Mcp/McpQueryStoreTools.cs index 40933c3..7f60c6e 100644 --- a/src/PlanViewer.App/Mcp/McpQueryStoreTools.cs +++ b/src/PlanViewer.App/Mcp/McpQueryStoreTools.cs @@ -70,7 +70,8 @@ public static async Task GetQueryStoreTop( [Description("Filter by Query Store plan ID.")] long? plan_id = null, [Description("Filter by query hash (hex, e.g. 0x1AB2C3D4).")] string? query_hash = null, [Description("Filter by query plan hash (hex, e.g. 0x1AB2C3D4).")] string? plan_hash = null, - [Description("Filter by module name (schema.name, supports % wildcards).")] string? module = null) + [Description("Filter by module name (schema.name, supports % wildcards).")] string? module = null, + [Description("Filter by execution type: regular, aborted, exception, or failed (= aborted + exception).")] string? execution_type = null) { try { @@ -84,9 +85,20 @@ public static async Task GetQueryStoreTop( if (hours_back < 1 || hours_back > 168) return "Invalid hours_back value. Must be between 1 and 168."; + string[]? executionTypes; + try + { + executionTypes = QueryStoreFilter.ParseExecutionType(execution_type); + } + catch (ArgumentException ex) + { + return ex.Message; + } + QueryStoreFilter? filter = null; if (query_id != null || plan_id != null || - query_hash != null || plan_hash != null || module != null) + query_hash != null || plan_hash != null || module != null || + executionTypes != null) { filter = new QueryStoreFilter { @@ -95,6 +107,7 @@ public static async Task GetQueryStoreTop( QueryHash = query_hash, QueryPlanHash = plan_hash, ModuleName = module, + ExecutionTypeDescs = executionTypes, }; } diff --git a/src/PlanViewer.Cli/Commands/QueryStoreCommand.cs b/src/PlanViewer.Cli/Commands/QueryStoreCommand.cs index 989bace..5908ed9 100644 --- a/src/PlanViewer.Cli/Commands/QueryStoreCommand.cs +++ b/src/PlanViewer.Cli/Commands/QueryStoreCommand.cs @@ -129,12 +129,18 @@ public static Command Create(ICredentialService? credentialService = null) Description = "Filter by module name (schema.name, supports % wildcards)" }; + var executionTypeOption = new Option("--execution-type") + { + Description = "Filter by execution type: regular, aborted, exception, or failed (= aborted + exception)" + }; + var cmd = new Command("query-store", "Analyze top queries from Query Store") { serverOption, databaseOption, topOption, orderByOption, hoursBackOption, outputDirOption, outputOption, compactOption, warningsOnlyOption, configOption, authOption, trustCertOption, loginOption, passwordOption, passwordStdinOption, - queryIdOption, planIdOption, queryHashOption, planHashOption, moduleOption + queryIdOption, planIdOption, queryHashOption, planHashOption, moduleOption, + executionTypeOption }; cmd.SetAction(async (parseResult, ct) => @@ -159,6 +165,7 @@ public static Command Create(ICredentialService? credentialService = null) var filterQueryHash = parseResult.GetValue(queryHashOption); var filterPlanHash = parseResult.GetValue(planHashOption); var filterModule = parseResult.GetValue(moduleOption); + var filterExecutionType = parseResult.GetValue(executionTypeOption); // Load .env file if present (CLI args take precedence) var env = ConnectionHelper.LoadEnvFile(); @@ -190,9 +197,22 @@ public static Command Create(ICredentialService? credentialService = null) return; } + string[]? executionTypes; + try + { + executionTypes = QueryStoreFilter.ParseExecutionType(filterExecutionType); + } + catch (ArgumentException ex) + { + Console.Error.WriteLine(ex.Message); + Environment.ExitCode = 1; + return; + } + QueryStoreFilter? filter = null; if (filterQueryId != null || filterPlanId != null || - filterQueryHash != null || filterPlanHash != null || filterModule != null) + filterQueryHash != null || filterPlanHash != null || filterModule != null || + executionTypes != null) { filter = new QueryStoreFilter { @@ -201,6 +221,7 @@ public static Command Create(ICredentialService? credentialService = null) QueryHash = filterQueryHash, QueryPlanHash = filterPlanHash, ModuleName = filterModule, + ExecutionTypeDescs = executionTypes, }; } diff --git a/src/PlanViewer.Core/Models/QueryStorePlan.cs b/src/PlanViewer.Core/Models/QueryStorePlan.cs index f5ef750..60df844 100644 --- a/src/PlanViewer.Core/Models/QueryStorePlan.cs +++ b/src/PlanViewer.Core/Models/QueryStorePlan.cs @@ -18,6 +18,26 @@ public class QueryStoreFilter /// Single value → equality predicate; multiple values (e.g. "Aborted","Exception" for "Failed") → IN predicate. /// public string[]? ExecutionTypeDescs { get; set; } + + /// + /// Parses a user-friendly execution-type string into the matching SQL execution_type_desc values. + /// Accepts (case-insensitive): regular, aborted, exception, failed (= aborted + exception), any. + /// Returns null when input is null, empty, or "any". Throws ArgumentException for unknown values. + /// + public static string[]? ParseExecutionType(string? input) + { + if (string.IsNullOrWhiteSpace(input)) return null; + return input.Trim().ToLowerInvariant() switch + { + "any" => null, + "regular" => ["Regular"], + "aborted" => ["Aborted"], + "exception" => ["Exception"], + "failed" => ["Aborted", "Exception"], + _ => throw new ArgumentException( + $"Unknown execution type '{input}'. Valid values: regular, aborted, exception, failed, any."), + }; + } } public class QueryStorePlan From ef6142c803ba0fd0bab7ca038ce18f8584d45990 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Tue, 12 May 2026 22:28:43 -0500 Subject: [PATCH 13/27] Split PlanViewerControl.axaml.cs into partial classes (#327) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add "Powered by Performance Studio" line on landing page Co-Authored-By: Claude Opus 4.6 (1M context) * Add Darling Data favicon to web app Co-Authored-By: Claude Opus 4.6 (1M context) * Add Open Graph and Twitter Card meta tags for social sharing Uses the Darling Data barbell logo as hero image when shared on social media. Also adds meta description for SEO. Co-Authored-By: Claude Opus 4.6 (1M context) * Clarify OG description: in-browser, nothing to install Co-Authored-By: Claude Opus 4.6 (1M context) * Fix Rule 3 severity: CouldNotGenerateValidParallelPlan is actionable This reason means something in the query blocks parallelism (scalar UDFs, table variable inserts, etc.) — that's worth a Warning, not Info. Co-Authored-By: Claude Opus 4.6 (1M context) * Expand Rule 3 to cover all NonParallelPlanReason values Adds human-readable messages for all 25 known reasons. Severity: - Warning: actionable reasons (UDFs, cursors, table variables, remote queries, trace flags, hints, DML OUTPUT, writeback variables) - Info: passive/environmental (cost below threshold, edition limits, memory-optimized tables, upgrade mode, index build edge cases) Co-Authored-By: Claude Opus 4.6 (1M context) * Split PlanViewerControl.axaml.cs into partial classes Move-only refactor; no behavior changes. PlanViewerControl.axaml.cs (4,497 lines) split into 7 partials: Rendering (550) - RenderStatement/Nodes/Edges + edge tooltips Properties (1860) - ShowPropertiesPanel + all property/runtime/wait panels Tooltips (278) - BuildNodeTooltipContent + helpers Interaction (327) - Node_Click, Select, zoom, pointer events, save Statements (222) - statements grid panel Minimap (502) - minimap render/drag/resize/navigation Schema (347) - context-menu schema lookup + index/column formatting Main file (PlanViewerControl.axaml.cs) now 504 lines — fields, brushes, constructor, public API (LoadPlan/Clear/NavigateToNode), connection state, and PlanConnect/PlanDatabase event handlers. Build clean: 0 errors, 0 warnings on PlanViewer.sln. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- .../Controls/PlanViewerControl.Interaction.cs | 327 ++ .../Controls/PlanViewerControl.Minimap.cs | 502 +++ .../Controls/PlanViewerControl.Properties.cs | 1860 ++++++++ .../Controls/PlanViewerControl.Rendering.cs | 550 +++ .../Controls/PlanViewerControl.Schema.cs | 347 ++ .../Controls/PlanViewerControl.Statements.cs | 222 + .../Controls/PlanViewerControl.Tooltips.cs | 278 ++ .../Controls/PlanViewerControl.axaml.cs | 3995 +---------------- 8 files changed, 4087 insertions(+), 3994 deletions(-) create mode 100644 src/PlanViewer.App/Controls/PlanViewerControl.Interaction.cs create mode 100644 src/PlanViewer.App/Controls/PlanViewerControl.Minimap.cs create mode 100644 src/PlanViewer.App/Controls/PlanViewerControl.Properties.cs create mode 100644 src/PlanViewer.App/Controls/PlanViewerControl.Rendering.cs create mode 100644 src/PlanViewer.App/Controls/PlanViewerControl.Schema.cs create mode 100644 src/PlanViewer.App/Controls/PlanViewerControl.Statements.cs create mode 100644 src/PlanViewer.App/Controls/PlanViewerControl.Tooltips.cs diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.Interaction.cs b/src/PlanViewer.App/Controls/PlanViewerControl.Interaction.cs new file mode 100644 index 0000000..0848235 --- /dev/null +++ b/src/PlanViewer.App/Controls/PlanViewerControl.Interaction.cs @@ -0,0 +1,327 @@ +using System; +using System.IO; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Media; +using Avalonia.Platform.Storage; +using PlanViewer.Core.Models; + +namespace PlanViewer.App.Controls; + +public partial class PlanViewerControl : UserControl +{ + private void Node_Click(object? sender, PointerPressedEventArgs e) + { + if (sender is Border border + && e.GetCurrentPoint(border).Properties.IsLeftButtonPressed + && _nodeBorderMap.TryGetValue(border, out var node)) + { + SelectNode(border, node); + e.Handled = true; + } + } + + private void SelectNode(Border border, PlanNode node) + { + // Deselect previous + if (_selectedNodeBorder != null) + { + _selectedNodeBorder.BorderBrush = _selectedNodeOriginalBorder; + _selectedNodeBorder.BorderThickness = _selectedNodeOriginalThickness; + } + + // Select new + _selectedNodeOriginalBorder = border.BorderBrush; + _selectedNodeOriginalThickness = border.BorderThickness; + _selectedNodeBorder = border; + border.BorderBrush = SelectionBrush; + border.BorderThickness = new Thickness(2); + + _selectedNode = node; + ShowPropertiesPanel(node); + UpdateMinimapSelection(node); + } + + private ContextMenu BuildNodeContextMenu(PlanNode node) + { + var menu = new ContextMenu(); + + var propsItem = new MenuItem { Header = "Properties" }; + propsItem.Click += (_, _) => + { + foreach (var child in PlanCanvas.Children) + { + if (child is Border b && _nodeBorderMap.TryGetValue(b, out var n) && n == node) + { + SelectNode(b, node); + break; + } + } + }; + menu.Items.Add(propsItem); + + menu.Items.Add(new Separator()); + + var copyOpItem = new MenuItem { Header = "Copy Operator Name" }; + copyOpItem.Click += async (_, _) => await SetClipboardTextAsync(node.PhysicalOp); + menu.Items.Add(copyOpItem); + + if (!string.IsNullOrEmpty(node.FullObjectName)) + { + var copyObjItem = new MenuItem { Header = "Copy Object Name" }; + copyObjItem.Click += async (_, _) => await SetClipboardTextAsync(node.FullObjectName!); + menu.Items.Add(copyObjItem); + } + + if (!string.IsNullOrEmpty(node.Predicate)) + { + var copyPredItem = new MenuItem { Header = "Copy Predicate" }; + copyPredItem.Click += async (_, _) => await SetClipboardTextAsync(node.Predicate!); + menu.Items.Add(copyPredItem); + } + + if (!string.IsNullOrEmpty(node.SeekPredicates)) + { + var copySeekItem = new MenuItem { Header = "Copy Seek Predicate" }; + copySeekItem.Click += async (_, _) => await SetClipboardTextAsync(node.SeekPredicates!); + menu.Items.Add(copySeekItem); + } + + // Schema lookup items (Show Indexes, Show Table Definition) + AddSchemaMenuItems(menu, node); + + return menu; + } + + private ContextMenu BuildCanvasContextMenu() + { + var menu = new ContextMenu(); + + // Zoom + var zoomInItem = new MenuItem { Header = "Zoom In" }; + zoomInItem.Click += (_, _) => SetZoom(_zoomLevel + ZoomStep); + menu.Items.Add(zoomInItem); + + var zoomOutItem = new MenuItem { Header = "Zoom Out" }; + zoomOutItem.Click += (_, _) => SetZoom(_zoomLevel - ZoomStep); + menu.Items.Add(zoomOutItem); + + var fitItem = new MenuItem { Header = "Fit to View" }; + fitItem.Click += ZoomFit_Click; + menu.Items.Add(fitItem); + + menu.Items.Add(new Separator()); + + // Advice + var humanAdviceItem = new MenuItem { Header = "Human Advice" }; + humanAdviceItem.Click += (_, _) => HumanAdviceRequested?.Invoke(this, EventArgs.Empty); + menu.Items.Add(humanAdviceItem); + + var robotAdviceItem = new MenuItem { Header = "Robot Advice" }; + robotAdviceItem.Click += (_, _) => RobotAdviceRequested?.Invoke(this, EventArgs.Empty); + menu.Items.Add(robotAdviceItem); + + menu.Items.Add(new Separator()); + + // Repro & Save + var copyReproItem = new MenuItem { Header = "Copy Repro Script" }; + copyReproItem.Click += (_, _) => CopyReproRequested?.Invoke(this, EventArgs.Empty); + menu.Items.Add(copyReproItem); + + var saveItem = new MenuItem { Header = "Save .sqlplan" }; + saveItem.Click += SavePlan_Click; + menu.Items.Add(saveItem); + + return menu; + } + + private async System.Threading.Tasks.Task SetClipboardTextAsync(string text) + { + var topLevel = TopLevel.GetTopLevel(this); + if (topLevel?.Clipboard != null) + await topLevel.Clipboard.SetTextAsync(text); + } + + private void ZoomIn_Click(object? sender, RoutedEventArgs e) => SetZoom(_zoomLevel + ZoomStep); + + private void ZoomOut_Click(object? sender, RoutedEventArgs e) => SetZoom(_zoomLevel - ZoomStep); + + private void ZoomFit_Click(object? sender, RoutedEventArgs e) + { + if (PlanCanvas.Width <= 0 || PlanCanvas.Height <= 0) return; + + var viewWidth = PlanScrollViewer.Bounds.Width; + var viewHeight = PlanScrollViewer.Bounds.Height; + if (viewWidth <= 0 || viewHeight <= 0) return; + + var fitZoom = Math.Min(viewWidth / PlanCanvas.Width, viewHeight / PlanCanvas.Height); + SetZoom(Math.Min(fitZoom, 1.0)); + PlanScrollViewer.Offset = new Avalonia.Vector(0, 0); + } + + private void SetZoom(double level) + { + _zoomLevel = Math.Max(MinZoom, Math.Min(MaxZoom, level)); + _zoomTransform.ScaleX = _zoomLevel; + _zoomTransform.ScaleY = _zoomLevel; + ZoomLevelText.Text = $"{(int)(_zoomLevel * 100)}%"; + UpdateMinimapViewportBox(); + } + + /// + /// Sets the zoom level and adjusts the scroll offset so that the content point + /// under stays fixed in the viewport. + /// + private void SetZoomAtPoint(double level, Point viewportAnchor) + { + var newZoom = Math.Max(MinZoom, Math.Min(MaxZoom, level)); + if (Math.Abs(newZoom - _zoomLevel) < 0.001) + return; + + // Content point under the anchor at the current zoom level + var contentX = (PlanScrollViewer.Offset.X + viewportAnchor.X) / _zoomLevel; + var contentY = (PlanScrollViewer.Offset.Y + viewportAnchor.Y) / _zoomLevel; + + // Apply the new zoom + SetZoom(newZoom); + + // Adjust offset so the same content point stays under the anchor + var newOffsetX = Math.Max(0, contentX * _zoomLevel - viewportAnchor.X); + var newOffsetY = Math.Max(0, contentY * _zoomLevel - viewportAnchor.Y); + + Avalonia.Threading.Dispatcher.UIThread.Post(() => + { + PlanScrollViewer.Offset = new Vector(newOffsetX, newOffsetY); + UpdateMinimapViewportBox(); + }); + } + + private void PlanScrollViewer_PointerWheelChanged(object? sender, PointerWheelEventArgs e) + { + if (e.KeyModifiers.HasFlag(KeyModifiers.Control)) + { + e.Handled = true; + var newLevel = _zoomLevel + (e.Delta.Y > 0 ? ZoomStep : -ZoomStep); + SetZoomAtPoint(newLevel, e.GetPosition(PlanScrollViewer)); + } + } + + private void PlanScrollViewer_PointerPressed(object? sender, PointerPressedEventArgs e) + { + // Don't intercept scrollbar interactions + if (IsScrollBarAtPoint(e)) + return; + + var point = e.GetCurrentPoint(PlanScrollViewer); + var isMiddle = point.Properties.IsMiddleButtonPressed; + var isLeft = point.Properties.IsLeftButtonPressed; + + // Middle mouse always pans; left-click pans only on empty canvas (not on nodes) + if (isMiddle || (isLeft && !IsNodeAtPoint(e))) + { + _isPanning = true; + _panStart = point.Position; + _panStartOffsetX = PlanScrollViewer.Offset.X; + _panStartOffsetY = PlanScrollViewer.Offset.Y; + PlanScrollViewer.Cursor = new Cursor(StandardCursorType.SizeAll); + e.Pointer.Capture(PlanScrollViewer); + e.Handled = true; + } + } + + private void PlanScrollViewer_PointerMoved(object? sender, PointerEventArgs e) + { + if (!_isPanning) return; + + var current = e.GetPosition(PlanScrollViewer); + var dx = current.X - _panStart.X; + var dy = current.Y - _panStart.Y; + + var newX = Math.Max(0, _panStartOffsetX - dx); + var newY = Math.Max(0, _panStartOffsetY - dy); + + // Defer offset change so the ScrollViewer doesn't overwrite it during layout + Avalonia.Threading.Dispatcher.UIThread.Post(() => + { + PlanScrollViewer.Offset = new Vector(newX, newY); + UpdateMinimapViewportBox(); + }); + + e.Handled = true; + } + + private void PlanScrollViewer_PointerReleased(object? sender, PointerReleasedEventArgs e) + { + if (!_isPanning) return; + _isPanning = false; + PlanScrollViewer.Cursor = Cursor.Default; + e.Pointer.Capture(null); + e.Handled = true; + } + + /// Check if the pointer event originated from a node Border. + private bool IsNodeAtPoint(PointerPressedEventArgs e) + { + // Walk up the visual tree from the source to see if we hit a node border + var source = e.Source as Control; + while (source != null && source != PlanCanvas) + { + if (source is Border b && _nodeBorderMap.ContainsKey(b)) + return true; + source = source.Parent as Control; + } + return false; + } + + /// Check if the pointer event originated from a ScrollBar. + private bool IsScrollBarAtPoint(PointerPressedEventArgs e) + { + var source = e.Source as Control; + while (source != null && source != PlanScrollViewer) + { + if (source is ScrollBar) + return true; + source = source.Parent as Control; + } + return false; + } + + private async void SavePlan_Click(object? sender, RoutedEventArgs e) + { + if (_currentPlan == null || string.IsNullOrEmpty(_currentPlan.RawXml)) return; + + var topLevel = TopLevel.GetTopLevel(this); + if (topLevel == null) return; + + var file = await topLevel.StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions + { + Title = "Save Plan", + DefaultExtension = "sqlplan", + SuggestedFileName = $"plan_{DateTime.Now:yyyyMMdd_HHmmss}.sqlplan", + FileTypeChoices = new[] + { + new FilePickerFileType("SQL Plan Files") { Patterns = new[] { "*.sqlplan" } }, + new FilePickerFileType("XML Files") { Patterns = new[] { "*.xml" } }, + new FilePickerFileType("All Files") { Patterns = new[] { "*.*" } } + } + }); + + if (file != null) + { + try + { + await using var stream = await file.OpenWriteAsync(); + await using var writer = new StreamWriter(stream); + await writer.WriteAsync(_currentPlan.RawXml); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"SavePlan failed: {ex.Message}"); + CostText.Text = $"Save failed: {(ex.Message.Length > 60 ? ex.Message[..60] + "..." : ex.Message)}"; + } + } + } +} diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.Minimap.cs b/src/PlanViewer.App/Controls/PlanViewerControl.Minimap.cs new file mode 100644 index 0000000..0e4424c --- /dev/null +++ b/src/PlanViewer.App/Controls/PlanViewerControl.Minimap.cs @@ -0,0 +1,502 @@ +using System; +using System.Collections.Generic; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Shapes; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Layout; +using Avalonia.Media; +using PlanViewer.App.Helpers; +using PlanViewer.App.Services; +using PlanViewer.Core.Models; +using PlanViewer.Core.Services; +using AvaloniaPath = Avalonia.Controls.Shapes.Path; + +namespace PlanViewer.App.Controls; + +public partial class PlanViewerControl : UserControl +{ + private void MinimapToggle_Click(object? sender, RoutedEventArgs e) + { + if (MinimapPanel.IsVisible) + CloseMinimapPanel(); + else + OpenMinimapPanel(); + } + + private void MinimapClose_Click(object? sender, RoutedEventArgs e) + { + CloseMinimapPanel(); + } + + private void OpenMinimapPanel() + { + MinimapPanel.Width = _minimapWidth; + MinimapPanel.Height = _minimapHeight; + MinimapPanel.IsVisible = true; + RenderMinimap(); + } + + private void CloseMinimapPanel() + { + MinimapPanel.IsVisible = false; + _minimapDragging = false; + _minimapResizing = false; + } + + private void RenderMinimap() + { + MinimapCanvas.Children.Clear(); + _minimapNodeMap.Clear(); + _minimapViewportBox = null; + _minimapSelectedNode = null; + + // Guard: don't render if the panel was closed between a deferred post and execution + if (!MinimapPanel.IsVisible) return; + + if (_currentStatement?.RootNode == null || PlanCanvas.Width <= 0 || PlanCanvas.Height <= 0) + return; + + var canvasW = MinimapCanvas.Bounds.Width; + var canvasH = MinimapCanvas.Bounds.Height; + if (canvasW <= 0 || canvasH <= 0) + { + // Defer until layout is ready + Avalonia.Threading.Dispatcher.UIThread.Post(RenderMinimap, Avalonia.Threading.DispatcherPriority.Loaded); + return; + } + + var scaleX = canvasW / PlanCanvas.Width; + var scaleY = canvasH / PlanCanvas.Height; + var scale = Math.Min(scaleX, scaleY); + + // Cache the non-expensive node border brush for this render cycle + _minimapNodeBorderBrushCache = FindBrushResource("ForegroundBrush") is SolidColorBrush fg + ? new SolidColorBrush(Color.FromArgb(0x80, fg.Color.R, fg.Color.G, fg.Color.B)) + : FindBrushResource("BorderBrush"); + + // Render branch areas with transparent colored backgrounds + RenderMinimapBranches(_currentStatement.RootNode, scale); + + // Render edges + var minimapDivergenceLimit = Math.Max(2.0, AppSettingsService.Load().AccuracyRatioDivergenceLimit); + RenderMinimapEdges(_currentStatement.RootNode, scale, minimapDivergenceLimit); + + // Render nodes + RenderMinimapNodes(_currentStatement.RootNode, scale); + + // Render viewport indicator + RenderMinimapViewportBox(scale); + + // Re-apply selection highlight if a node is selected + if (_selectedNode != null) + UpdateMinimapSelection(_selectedNode); + } + + private void RenderMinimapBranches(PlanNode root, double scale) + { + + for (int i = 0; i < root.Children.Count; i++) + { + var child = root.Children[i]; + var color = MinimapBranchColors[i % MinimapBranchColors.Length]; + + // Collect bounds of all nodes in this subtree + double minX = double.MaxValue, minY = double.MaxValue; + double maxX = double.MinValue, maxY = double.MinValue; + CollectSubtreeBounds(child, ref minX, ref minY, ref maxX, ref maxY); + + var rect = new Avalonia.Controls.Shapes.Rectangle + { + Width = (maxX - minX + PlanLayoutEngine.NodeWidth) * scale + 4, + Height = (maxY - minY + PlanLayoutEngine.GetNodeHeight(child)) * scale + 4, + Fill = new SolidColorBrush(color), + RadiusX = 2, + RadiusY = 2 + }; + Canvas.SetLeft(rect, minX * scale - 2); + Canvas.SetTop(rect, minY * scale - 2); + MinimapCanvas.Children.Add(rect); + } + } + + private static void CollectSubtreeBounds(PlanNode node, ref double minX, ref double minY, ref double maxX, ref double maxY) + { + if (node.X < minX) minX = node.X; + if (node.Y < minY) minY = node.Y; + if (node.X > maxX) maxX = node.X; + var bottom = node.Y + PlanLayoutEngine.GetNodeHeight(node); + if (bottom > maxY) maxY = bottom; + + foreach (var child in node.Children) + CollectSubtreeBounds(child, ref minX, ref minY, ref maxX, ref maxY); + } + + private void RenderMinimapEdges(PlanNode node, double scale, double divergenceLimit) + { + foreach (var child in node.Children) + { + var parentRight = (node.X + PlanLayoutEngine.NodeWidth) * scale; + var parentCenterY = (node.Y + PlanLayoutEngine.GetNodeHeight(node) / 2) * scale; + var childLeft = child.X * scale; + var childCenterY = (child.Y + PlanLayoutEngine.GetNodeHeight(child) / 2) * scale; + var midX = (parentRight + childLeft) / 2; + + // Proportional thickness matching the plan viewer (logarithmic, scaled down) + var rows = child.HasActualStats ? child.ActualRows : child.EstimateRows; + var fullThickness = Math.Max(2, Math.Min(Math.Floor(Math.Log(Math.Max(1, rows))), 12)); + var thickness = Math.Max(0.5, fullThickness * scale); + + var geometry = new PathGeometry(); + var figure = new PathFigure { StartPoint = new Point(parentRight, parentCenterY), IsClosed = false }; + figure.Segments!.Add(new LineSegment { Point = new Point(midX, parentCenterY) }); + figure.Segments.Add(new LineSegment { Point = new Point(midX, childCenterY) }); + figure.Segments.Add(new LineSegment { Point = new Point(childLeft, childCenterY) }); + geometry.Figures!.Add(figure); + + var linkBrush = GetLinkColorBrush(child, divergenceLimit); + + var path = new AvaloniaPath + { + Data = geometry, + Stroke = linkBrush, + StrokeThickness = thickness, + StrokeJoin = PenLineJoin.Round + }; + MinimapCanvas.Children.Add(path); + + RenderMinimapEdges(child, scale, divergenceLimit); + } + } + + private void RenderMinimapNodes(PlanNode node, double scale) + { + var w = PlanLayoutEngine.NodeWidth * scale; + var h = PlanLayoutEngine.GetNodeHeight(node) * scale; + // Use theme background colors with transparency + var bgBrush = node.IsExpensive + ? MinimapExpensiveNodeBgBrush + : FindBrushResource("BackgroundLightBrush"); + var borderBrush = node.IsExpensive ? OrangeRedBrush : _minimapNodeBorderBrushCache; + + var border = new Border + { + Width = Math.Max(4, w), + Height = Math.Max(4, h), + Background = bgBrush, + BorderBrush = borderBrush, + BorderThickness = new Thickness(0.5), + CornerRadius = new CornerRadius(1) + }; + + // Show a small icon inside the node if space allows + var iconBitmap = IconHelper.LoadIcon(node.IconName); + if (iconBitmap != null) + { + var iconSize = Math.Min(Math.Min(w * 0.7, h * 0.7), 16); + if (iconSize >= 6) + { + border.Child = new Image + { + Source = iconBitmap, + Width = iconSize, + Height = iconSize, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center + }; + } + } + + Canvas.SetLeft(border, node.X * scale); + Canvas.SetTop(border, node.Y * scale); + MinimapCanvas.Children.Add(border); + + _minimapNodeMap[border] = node; + + foreach (var child in node.Children) + RenderMinimapNodes(child, scale); + } + + private void RenderMinimapViewportBox(double scale) + { + var viewW = PlanScrollViewer.Bounds.Width; + var viewH = PlanScrollViewer.Bounds.Height; + if (viewW <= 0 || viewH <= 0) return; + + var contentW = PlanCanvas.Width * _zoomLevel; + var contentH = PlanCanvas.Height * _zoomLevel; + + var boxW = Math.Min(viewW / contentW, 1.0) * PlanCanvas.Width * scale; + var boxH = Math.Min(viewH / contentH, 1.0) * PlanCanvas.Height * scale; + var boxX = (PlanScrollViewer.Offset.X / _zoomLevel) * scale; + var boxY = (PlanScrollViewer.Offset.Y / _zoomLevel) * scale; + + var accentColor = FindBrushResource("AccentBrush") is SolidColorBrush ab + ? ab.Color + : Color.FromRgb(0x2E, 0xAE, 0xF1); + var themeBrush = new SolidColorBrush(Color.FromArgb(0x40, accentColor.R, accentColor.G, accentColor.B)); + var borderBrush = new SolidColorBrush(Color.FromArgb(0xB0, accentColor.R, accentColor.G, accentColor.B)); + + _minimapViewportBox = new Border + { + Width = Math.Max(4, boxW), + Height = Math.Max(4, boxH), + Background = themeBrush, + BorderBrush = borderBrush, + BorderThickness = new Thickness(1.5), + CornerRadius = new CornerRadius(1), + Cursor = new Cursor(StandardCursorType.SizeAll) + }; + Canvas.SetLeft(_minimapViewportBox, boxX); + Canvas.SetTop(_minimapViewportBox, boxY); + MinimapCanvas.Children.Add(_minimapViewportBox); + } + + private void UpdateMinimapViewportBox() + { + if (!MinimapPanel.IsVisible || _minimapViewportBox == null || _currentStatement?.RootNode == null) + return; + if (PlanCanvas.Width <= 0 || PlanCanvas.Height <= 0) return; + + var canvasW = MinimapCanvas.Bounds.Width; + var canvasH = MinimapCanvas.Bounds.Height; + if (canvasW <= 0 || canvasH <= 0) return; + + var scaleX = canvasW / PlanCanvas.Width; + var scaleY = canvasH / PlanCanvas.Height; + var scale = Math.Min(scaleX, scaleY); + + var viewW = PlanScrollViewer.Bounds.Width; + var viewH = PlanScrollViewer.Bounds.Height; + if (viewW <= 0 || viewH <= 0) return; + + var contentW = PlanCanvas.Width * _zoomLevel; + var contentH = PlanCanvas.Height * _zoomLevel; + + _minimapViewportBox.Width = Math.Max(4, Math.Min(viewW / contentW, 1.0) * PlanCanvas.Width * scale); + _minimapViewportBox.Height = Math.Max(4, Math.Min(viewH / contentH, 1.0) * PlanCanvas.Height * scale); + Canvas.SetLeft(_minimapViewportBox, (PlanScrollViewer.Offset.X / _zoomLevel) * scale); + Canvas.SetTop(_minimapViewportBox, (PlanScrollViewer.Offset.Y / _zoomLevel) * scale); + } + + private double GetMinimapScale() + { + if (PlanCanvas.Width <= 0 || PlanCanvas.Height <= 0) return 1; + var canvasW = MinimapCanvas.Bounds.Width; + var canvasH = MinimapCanvas.Bounds.Height; + if (canvasW <= 0 || canvasH <= 0) return 1; + return Math.Min(canvasW / PlanCanvas.Width, canvasH / PlanCanvas.Height); + } + + private void UpdateMinimapSelection(PlanNode node) + { + if (!MinimapPanel.IsVisible) return; + + // Reset previous selection highlight + if (_minimapSelectedNode != null) + { + var prevNode = _minimapNodeMap.GetValueOrDefault(_minimapSelectedNode); + _minimapSelectedNode.BorderBrush = prevNode is { IsExpensive: true } + ? OrangeRedBrush + : _minimapNodeBorderBrushCache; + _minimapSelectedNode.BorderThickness = new Thickness(0.5); + _minimapSelectedNode = null; + } + + // Find and highlight the new node + foreach (var (border, n) in _minimapNodeMap) + { + if (n == node) + { + border.BorderBrush = SelectionBrush; + border.BorderThickness = new Thickness(2); + _minimapSelectedNode = border; + break; + } + } + } + + private void MinimapCanvas_PointerPressed(object? sender, PointerPressedEventArgs e) + { + var point = e.GetCurrentPoint(MinimapCanvas); + if (!point.Properties.IsLeftButtonPressed) return; + + var pos = point.Position; + var scale = GetMinimapScale(); + + // Check if clicking on a node (single click = center, double click = zoom) + if (e.ClickCount == 2) + { + // Double click: find node under pointer and zoom to it + var node = FindMinimapNodeAt(pos); + if (node != null) + { + ZoomToNode(node); + e.Handled = true; + return; + } + } + + if (e.ClickCount == 1) + { + // Check if over a minimap node for single-click centering + var node = FindMinimapNodeAt(pos); + if (node != null) + { + CenterOnNode(node); + e.Handled = true; + return; + } + } + + // Start viewport box drag + _minimapDragging = true; + + // Move viewport center to click position + ScrollPlanViewerToMinimapPoint(pos, scale); + + e.Pointer.Capture(MinimapCanvas); + e.Handled = true; + } + + private void MinimapCanvas_PointerMoved(object? sender, PointerEventArgs e) + { + if (!_minimapDragging) return; + + var pos = e.GetPosition(MinimapCanvas); + var scale = GetMinimapScale(); + ScrollPlanViewerToMinimapPoint(pos, scale); + e.Handled = true; + } + + private void MinimapCanvas_PointerReleased(object? sender, PointerReleasedEventArgs e) + { + if (!_minimapDragging) return; + _minimapDragging = false; + e.Pointer.Capture(null); + e.Handled = true; + } + + private void ScrollPlanViewerToMinimapPoint(Point minimapPoint, double scale) + { + if (scale <= 0) return; + // Convert minimap coords to plan content coords + var contentX = minimapPoint.X / scale; + var contentY = minimapPoint.Y / scale; + + // Center the viewport on this content point + var viewW = PlanScrollViewer.Bounds.Width; + var viewH = PlanScrollViewer.Bounds.Height; + var offsetX = Math.Max(0, contentX * _zoomLevel - viewW / 2); + var offsetY = Math.Max(0, contentY * _zoomLevel - viewH / 2); + + Avalonia.Threading.Dispatcher.UIThread.Post(() => + { + PlanScrollViewer.Offset = new Vector(offsetX, offsetY); + }); + } + + private PlanNode? FindMinimapNodeAt(Point pos) + { + foreach (var (border, node) in _minimapNodeMap) + { + var left = Canvas.GetLeft(border); + var top = Canvas.GetTop(border); + if (pos.X >= left && pos.X <= left + border.Width && + pos.Y >= top && pos.Y <= top + border.Height) + return node; + } + return null; + } + + private void CenterOnNode(PlanNode node) + { + var nodeW = PlanLayoutEngine.NodeWidth; + var nodeH = PlanLayoutEngine.GetNodeHeight(node); + var viewW = PlanScrollViewer.Bounds.Width; + var viewH = PlanScrollViewer.Bounds.Height; + var centerX = (node.X + nodeW / 2) * _zoomLevel - viewW / 2; + var centerY = (node.Y + nodeH / 2) * _zoomLevel - viewH / 2; + centerX = Math.Max(0, centerX); + centerY = Math.Max(0, centerY); + + Avalonia.Threading.Dispatcher.UIThread.Post(() => + { + PlanScrollViewer.Offset = new Vector(centerX, centerY); + }); + } + + private void ZoomToNode(PlanNode node) + { + var viewW = PlanScrollViewer.Bounds.Width; + var viewH = PlanScrollViewer.Bounds.Height; + if (viewW <= 0 || viewH <= 0) return; + + var nodeW = PlanLayoutEngine.NodeWidth; + var nodeH = PlanLayoutEngine.GetNodeHeight(node); + + // Zoom so the node takes about 1/3 of the viewport + var fitZoom = Math.Min(viewW / (nodeW * 3), viewH / (nodeH * 3)); + fitZoom = Math.Max(MinZoom, Math.Min(MaxZoom, fitZoom)); + SetZoom(fitZoom); + + // Center on the node + var centerX = (node.X + nodeW / 2) * _zoomLevel - viewW / 2; + var centerY = (node.Y + nodeH / 2) * _zoomLevel - viewH / 2; + + Avalonia.Threading.Dispatcher.UIThread.Post(() => + { + PlanScrollViewer.Offset = new Vector(Math.Max(0, centerX), Math.Max(0, centerY)); + }); + + // Also select the node in the plan + foreach (var (border, n) in _nodeBorderMap) + { + if (n == node) + { + SelectNode(border, node); + break; + } + } + } + + private void MinimapResizeGrip_PointerPressed(object? sender, PointerPressedEventArgs e) + { + var point = e.GetCurrentPoint(MinimapPanel); + if (!point.Properties.IsLeftButtonPressed) return; + _minimapResizing = true; + _minimapResizeStart = point.Position; + _minimapResizeStartW = MinimapPanel.Width; + _minimapResizeStartH = MinimapPanel.Height; + e.Pointer.Capture((Control)sender!); + e.Handled = true; + } + + private void MinimapResizeGrip_PointerMoved(object? sender, PointerEventArgs e) + { + if (!_minimapResizing) return; + var current = e.GetPosition(MinimapPanel); + var dx = current.X - _minimapResizeStart.X; + var dy = current.Y - _minimapResizeStart.Y; + var newW = Math.Max(MinimapMinSize, Math.Min(MinimapMaxSize, _minimapResizeStartW + dx)); + var newH = Math.Max(MinimapMinSize, Math.Min(MinimapMaxSize, _minimapResizeStartH + dy)); + MinimapPanel.Width = newW; + MinimapPanel.Height = newH; + _minimapWidth = newW; + _minimapHeight = newH; + e.Handled = true; + + // Re-render after resize + Avalonia.Threading.Dispatcher.UIThread.Post(RenderMinimap, Avalonia.Threading.DispatcherPriority.Background); + } + + private void MinimapResizeGrip_PointerReleased(object? sender, PointerReleasedEventArgs e) + { + if (!_minimapResizing) return; + _minimapResizing = false; + e.Pointer.Capture(null); + e.Handled = true; + RenderMinimap(); + } +} diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.Properties.cs b/src/PlanViewer.App/Controls/PlanViewerControl.Properties.cs new file mode 100644 index 0000000..24a05d4 --- /dev/null +++ b/src/PlanViewer.App/Controls/PlanViewerControl.Properties.cs @@ -0,0 +1,1860 @@ +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 ShowPropertiesPanel(PlanNode node) + { + PropertiesContent.Children.Clear(); + _sectionLabelColumns.Clear(); + _currentSectionGrid = null; + _currentSectionRowIndex = 0; + + // Header + var headerText = node.PhysicalOp; + if (node.LogicalOp != node.PhysicalOp && !string.IsNullOrEmpty(node.LogicalOp) + && !node.PhysicalOp.Contains(node.LogicalOp, StringComparison.OrdinalIgnoreCase)) + headerText += $" ({node.LogicalOp})"; + PropertiesHeader.Text = headerText; + PropertiesSubHeader.Text = $"Node ID: {node.NodeId}"; + + // === General Section === + AddPropertySection("General"); + AddPropertyRow("Physical Operation", node.PhysicalOp); + AddPropertyRow("Logical Operation", node.LogicalOp); + AddPropertyRow("Node ID", $"{node.NodeId}"); + if (!string.IsNullOrEmpty(node.ExecutionMode)) + AddPropertyRow("Execution Mode", node.ExecutionMode); + if (!string.IsNullOrEmpty(node.ActualExecutionMode) && node.ActualExecutionMode != node.ExecutionMode) + AddPropertyRow("Actual Exec Mode", node.ActualExecutionMode); + AddPropertyRow("Parallel", node.Parallel ? "True" : "False"); + if (node.Partitioned) + AddPropertyRow("Partitioned", "True"); + if (node.EstimatedDOP > 0) + AddPropertyRow("Estimated DOP", $"{node.EstimatedDOP}"); + + // Scan/seek-related properties + if (!string.IsNullOrEmpty(node.FullObjectName)) + { + AddPropertyRow("Ordered", node.Ordered ? "True" : "False"); + if (!string.IsNullOrEmpty(node.ScanDirection)) + AddPropertyRow("Scan Direction", node.ScanDirection); + AddPropertyRow("Forced Index", node.ForcedIndex ? "True" : "False"); + AddPropertyRow("ForceScan", node.ForceScan ? "True" : "False"); + AddPropertyRow("ForceSeek", node.ForceSeek ? "True" : "False"); + AddPropertyRow("NoExpandHint", node.NoExpandHint ? "True" : "False"); + if (node.Lookup) + AddPropertyRow("Lookup", "True"); + if (node.DynamicSeek) + AddPropertyRow("Dynamic Seek", "True"); + } + + if (!string.IsNullOrEmpty(node.StorageType)) + AddPropertyRow("Storage", node.StorageType); + if (node.IsAdaptive) + AddPropertyRow("Adaptive", "True"); + if (node.SpillOccurredDetail) + AddPropertyRow("Spill Occurred", "True"); + + // === Object Section === + if (!string.IsNullOrEmpty(node.FullObjectName)) + { + AddPropertySection("Object"); + AddPropertyRow("Full Name", node.FullObjectName, isCode: true); + if (!string.IsNullOrEmpty(node.ServerName)) + AddPropertyRow("Server", node.ServerName); + if (!string.IsNullOrEmpty(node.DatabaseName)) + AddPropertyRow("Database", node.DatabaseName); + if (!string.IsNullOrEmpty(node.ObjectAlias)) + AddPropertyRow("Alias", node.ObjectAlias); + if (!string.IsNullOrEmpty(node.IndexName)) + AddPropertyRow("Index", node.IndexName); + if (!string.IsNullOrEmpty(node.IndexKind)) + AddPropertyRow("Index Kind", node.IndexKind); + if (node.FilteredIndex) + AddPropertyRow("Filtered Index", "True"); + if (node.TableReferenceId > 0) + AddPropertyRow("Table Ref Id", $"{node.TableReferenceId}"); + } + + // === Operator Details Section === + var hasOperatorDetails = !string.IsNullOrEmpty(node.OrderBy) + || !string.IsNullOrEmpty(node.TopExpression) + || !string.IsNullOrEmpty(node.GroupBy) + || !string.IsNullOrEmpty(node.PartitionColumns) + || !string.IsNullOrEmpty(node.HashKeys) + || !string.IsNullOrEmpty(node.SegmentColumn) + || !string.IsNullOrEmpty(node.DefinedValues) + || !string.IsNullOrEmpty(node.OuterReferences) + || !string.IsNullOrEmpty(node.InnerSideJoinColumns) + || !string.IsNullOrEmpty(node.OuterSideJoinColumns) + || !string.IsNullOrEmpty(node.ActionColumn) + || node.ManyToMany || node.PhysicalOp == "Merge Join" || node.BitmapCreator + || node.SortDistinct || node.StartupExpression + || node.NLOptimized || node.WithOrderedPrefetch || node.WithUnorderedPrefetch + || node.WithTies || node.Remoting || node.LocalParallelism + || node.SpoolStack || node.DMLRequestSort || node.NonClusteredIndexCount > 0 + || !string.IsNullOrEmpty(node.OffsetExpression) || node.TopRows > 0 + || !string.IsNullOrEmpty(node.ConstantScanValues) + || !string.IsNullOrEmpty(node.UdxUsedColumns); + + if (hasOperatorDetails) + { + AddPropertySection("Operator Details"); + if (!string.IsNullOrEmpty(node.OrderBy)) + AddPropertyRow("Order By", node.OrderBy, isCode: true); + if (!string.IsNullOrEmpty(node.TopExpression)) + { + var topText = node.TopExpression; + if (node.IsPercent) topText += " PERCENT"; + if (node.WithTies) topText += " WITH TIES"; + AddPropertyRow("Top", topText); + } + if (node.SortDistinct) + AddPropertyRow("Distinct Sort", "True"); + if (node.StartupExpression) + AddPropertyRow("Startup Expression", "True"); + if (node.NLOptimized) + AddPropertyRow("Optimized", "True"); + if (node.WithOrderedPrefetch) + AddPropertyRow("Ordered Prefetch", "True"); + if (node.WithUnorderedPrefetch) + AddPropertyRow("Unordered Prefetch", "True"); + if (node.BitmapCreator) + AddPropertyRow("Bitmap Creator", "True"); + if (node.Remoting) + AddPropertyRow("Remoting", "True"); + if (node.LocalParallelism) + AddPropertyRow("Local Parallelism", "True"); + if (!string.IsNullOrEmpty(node.GroupBy)) + AddPropertyRow("Group By", node.GroupBy, isCode: true); + if (!string.IsNullOrEmpty(node.PartitionColumns)) + AddPropertyRow("Partition Columns", node.PartitionColumns, isCode: true); + if (!string.IsNullOrEmpty(node.HashKeys)) + AddPropertyRow("Hash Keys", node.HashKeys, isCode: true); + if (!string.IsNullOrEmpty(node.OffsetExpression)) + AddPropertyRow("Offset", node.OffsetExpression); + if (node.TopRows > 0) + AddPropertyRow("Rows", $"{node.TopRows}"); + if (node.SpoolStack) + AddPropertyRow("Stack Spool", "True"); + if (node.PrimaryNodeId > 0) + AddPropertyRow("Primary Node Id", $"{node.PrimaryNodeId}"); + if (node.DMLRequestSort) + AddPropertyRow("DML Request Sort", "True"); + if (node.NonClusteredIndexCount > 0) + { + AddPropertyRow("NC Indexes Maintained", $"{node.NonClusteredIndexCount}"); + foreach (var ixName in node.NonClusteredIndexNames) + AddPropertyRow("", ixName, isCode: true); + } + if (!string.IsNullOrEmpty(node.ActionColumn)) + AddPropertyRow("Action Column", node.ActionColumn, isCode: true); + if (!string.IsNullOrEmpty(node.SegmentColumn)) + AddPropertyRow("Segment Column", node.SegmentColumn, isCode: true); + if (!string.IsNullOrEmpty(node.DefinedValues)) + AddPropertyRow("Defined Values", node.DefinedValues, isCode: true); + if (!string.IsNullOrEmpty(node.OuterReferences)) + AddPropertyRow("Outer References", node.OuterReferences, isCode: true); + if (!string.IsNullOrEmpty(node.InnerSideJoinColumns)) + AddPropertyRow("Inner Join Cols", node.InnerSideJoinColumns, isCode: true); + if (!string.IsNullOrEmpty(node.OuterSideJoinColumns)) + AddPropertyRow("Outer Join Cols", node.OuterSideJoinColumns, isCode: true); + if (node.PhysicalOp == "Merge Join") + AddPropertyRow("Many to Many", node.ManyToMany ? "Yes" : "No"); + else if (node.ManyToMany) + AddPropertyRow("Many to Many", "Yes"); + if (!string.IsNullOrEmpty(node.ConstantScanValues)) + AddPropertyRow("Values", node.ConstantScanValues, isCode: true); + if (!string.IsNullOrEmpty(node.UdxUsedColumns)) + AddPropertyRow("UDX Columns", node.UdxUsedColumns, isCode: true); + if (node.RowCount) + AddPropertyRow("Row Count", "True"); + if (node.ForceSeekColumnCount > 0) + AddPropertyRow("ForceSeek Columns", $"{node.ForceSeekColumnCount}"); + if (!string.IsNullOrEmpty(node.PartitionId)) + AddPropertyRow("Partition Id", node.PartitionId, isCode: true); + if (node.IsStarJoin) + AddPropertyRow("Star Join Root", "True"); + if (!string.IsNullOrEmpty(node.StarJoinOperationType)) + AddPropertyRow("Star Join Type", node.StarJoinOperationType); + if (!string.IsNullOrEmpty(node.ProbeColumn)) + AddPropertyRow("Probe Column", node.ProbeColumn, isCode: true); + if (node.InRow) + AddPropertyRow("In-Row", "True"); + if (node.ComputeSequence) + AddPropertyRow("Compute Sequence", "True"); + if (node.RollupHighestLevel > 0) + AddPropertyRow("Rollup Highest Level", $"{node.RollupHighestLevel}"); + if (node.RollupLevels.Count > 0) + AddPropertyRow("Rollup Levels", string.Join(", ", node.RollupLevels)); + if (!string.IsNullOrEmpty(node.TvfParameters)) + AddPropertyRow("TVF Parameters", node.TvfParameters, isCode: true); + if (!string.IsNullOrEmpty(node.OriginalActionColumn)) + AddPropertyRow("Original Action Col", node.OriginalActionColumn, isCode: true); + if (!string.IsNullOrEmpty(node.TieColumns)) + AddPropertyRow("WITH TIES Columns", node.TieColumns, isCode: true); + if (!string.IsNullOrEmpty(node.UdxName)) + AddPropertyRow("UDX Name", node.UdxName); + if (node.GroupExecuted) + AddPropertyRow("Group Executed", "True"); + if (node.RemoteDataAccess) + AddPropertyRow("Remote Data Access", "True"); + if (node.OptimizedHalloweenProtectionUsed) + AddPropertyRow("Halloween Protection", "True"); + if (node.StatsCollectionId > 0) + AddPropertyRow("Stats Collection Id", $"{node.StatsCollectionId}"); + } + + // === Scalar UDFs === + if (node.ScalarUdfs.Count > 0) + { + AddPropertySection("Scalar UDFs"); + foreach (var udf in node.ScalarUdfs) + { + var udfDetail = udf.FunctionName; + if (udf.IsClrFunction) + { + udfDetail += " (CLR)"; + if (!string.IsNullOrEmpty(udf.ClrAssembly)) + udfDetail += $"\n Assembly: {udf.ClrAssembly}"; + if (!string.IsNullOrEmpty(udf.ClrClass)) + udfDetail += $"\n Class: {udf.ClrClass}"; + if (!string.IsNullOrEmpty(udf.ClrMethod)) + udfDetail += $"\n Method: {udf.ClrMethod}"; + } + AddPropertyRow("UDF", udfDetail, isCode: true); + } + } + + // === Named Parameters (IndexScan) === + if (node.NamedParameters.Count > 0) + { + AddPropertySection("Named Parameters"); + foreach (var np in node.NamedParameters) + AddPropertyRow(np.Name, np.ScalarString ?? "", isCode: true); + } + + // === Per-Operator Indexed Views === + if (node.OperatorIndexedViews.Count > 0) + { + AddPropertySection("Operator Indexed Views"); + foreach (var iv in node.OperatorIndexedViews) + AddPropertyRow("View", iv, isCode: true); + } + + // === Suggested Index (Eager Spool) === + if (!string.IsNullOrEmpty(node.SuggestedIndex)) + { + AddPropertySection("Suggested Index"); + AddPropertyRow("CREATE INDEX", node.SuggestedIndex, isCode: true); + } + + // === Remote Operator === + if (!string.IsNullOrEmpty(node.RemoteDestination) || !string.IsNullOrEmpty(node.RemoteSource) + || !string.IsNullOrEmpty(node.RemoteObject) || !string.IsNullOrEmpty(node.RemoteQuery)) + { + AddPropertySection("Remote Operator"); + if (!string.IsNullOrEmpty(node.RemoteDestination)) + AddPropertyRow("Destination", node.RemoteDestination); + if (!string.IsNullOrEmpty(node.RemoteSource)) + AddPropertyRow("Source", node.RemoteSource); + if (!string.IsNullOrEmpty(node.RemoteObject)) + AddPropertyRow("Object", node.RemoteObject, isCode: true); + if (!string.IsNullOrEmpty(node.RemoteQuery)) + AddPropertyRow("Query", node.RemoteQuery, isCode: true); + } + + // === Foreign Key References Section === + if (node.ForeignKeyReferencesCount > 0 || node.NoMatchingIndexCount > 0 || node.PartialMatchingIndexCount > 0) + { + AddPropertySection("Foreign Key References"); + if (node.ForeignKeyReferencesCount > 0) + AddPropertyRow("FK References", $"{node.ForeignKeyReferencesCount}"); + if (node.NoMatchingIndexCount > 0) + AddPropertyRow("No Matching Index", $"{node.NoMatchingIndexCount}"); + if (node.PartialMatchingIndexCount > 0) + AddPropertyRow("Partial Match Index", $"{node.PartialMatchingIndexCount}"); + } + + // === Adaptive Join Section === + if (node.IsAdaptive) + { + AddPropertySection("Adaptive Join"); + if (!string.IsNullOrEmpty(node.EstimatedJoinType)) + AddPropertyRow("Est. Join Type", node.EstimatedJoinType); + if (!string.IsNullOrEmpty(node.ActualJoinType)) + AddPropertyRow("Actual Join Type", node.ActualJoinType); + if (node.AdaptiveThresholdRows > 0) + AddPropertyRow("Threshold Rows", $"{node.AdaptiveThresholdRows:N1}"); + } + + // === Estimated Costs Section === + AddPropertySection("Estimated Costs"); + AddPropertyRow("Operator Cost", $"{node.EstimatedOperatorCost:F6} ({node.CostPercent}%)"); + AddPropertyRow("Subtree Cost", $"{node.EstimatedTotalSubtreeCost:F6}"); + AddPropertyRow("I/O Cost", $"{node.EstimateIO:F6}"); + AddPropertyRow("CPU Cost", $"{node.EstimateCPU:F6}"); + + // === Estimated Rows Section === + AddPropertySection("Estimated Rows"); + var estExecs = 1 + node.EstimateRebinds; + AddPropertyRow("Est. Executions", $"{estExecs:N0}"); + AddPropertyRow("Est. Rows Per Exec", $"{node.EstimateRows:N1}"); + AddPropertyRow("Est. Rows All Execs", $"{node.EstimateRows * Math.Max(1, estExecs):N1}"); + if (node.EstimatedRowsRead > 0) + AddPropertyRow("Est. Rows to Read", $"{node.EstimatedRowsRead:N1}"); + if (node.EstimateRowsWithoutRowGoal > 0) + AddPropertyRow("Est. Rows (No Row Goal)", $"{node.EstimateRowsWithoutRowGoal:N1}"); + if (node.TableCardinality > 0) + AddPropertyRow("Table Cardinality", $"{node.TableCardinality:N0}"); + AddPropertyRow("Avg Row Size", $"{node.EstimatedRowSize} B"); + AddPropertyRow("Est. Rebinds", $"{node.EstimateRebinds:N1}"); + AddPropertyRow("Est. Rewinds", $"{node.EstimateRewinds:N1}"); + + // === Actual Stats Section (if actual plan) === + if (node.HasActualStats) + { + AddPropertySection("Actual Statistics"); + AddPropertyRow("Actual Rows", $"{node.ActualRows:N0}"); + if (node.PerThreadStats.Count > 1) + foreach (var t in node.PerThreadStats) + AddPropertyRow($" Thread {t.ThreadId}", $"{t.ActualRows:N0}", indent: true); + if (node.ActualRowsRead > 0) + { + AddPropertyRow("Actual Rows Read", $"{node.ActualRowsRead:N0}"); + if (node.PerThreadStats.Count > 1) + foreach (var t in node.PerThreadStats.Where(t => t.ActualRowsRead > 0)) + AddPropertyRow($" Thread {t.ThreadId}", $"{t.ActualRowsRead:N0}", indent: true); + } + AddPropertyRow("Actual Executions", $"{node.ActualExecutions:N0}"); + if (node.PerThreadStats.Count > 1) + foreach (var t in node.PerThreadStats) + AddPropertyRow($" Thread {t.ThreadId}", $"{t.ActualExecutions:N0}", indent: true); + if (node.ActualRebinds > 0) + AddPropertyRow("Actual Rebinds", $"{node.ActualRebinds:N0}"); + if (node.ActualRewinds > 0) + AddPropertyRow("Actual Rewinds", $"{node.ActualRewinds:N0}"); + + // Runtime partition summary + if (node.PartitionsAccessed > 0) + { + AddPropertyRow("Partitions Accessed", $"{node.PartitionsAccessed}"); + if (!string.IsNullOrEmpty(node.PartitionRanges)) + AddPropertyRow("Partition Ranges", node.PartitionRanges); + } + + // Timing + if (node.ActualElapsedMs > 0 || node.ActualCPUMs > 0 + || node.UdfCpuTimeMs > 0 || node.UdfElapsedTimeMs > 0) + { + AddPropertySection("Actual Timing"); + if (node.ActualElapsedMs > 0) + { + AddPropertyRow("Elapsed Time", $"{node.ActualElapsedMs:N0} ms"); + if (node.PerThreadStats.Count > 1) + foreach (var t in node.PerThreadStats.Where(t => t.ActualElapsedMs > 0)) + AddPropertyRow($" Thread {t.ThreadId}", $"{t.ActualElapsedMs:N0} ms", indent: true); + } + if (node.ActualCPUMs > 0) + { + AddPropertyRow("CPU Time", $"{node.ActualCPUMs:N0} ms"); + if (node.PerThreadStats.Count > 1) + foreach (var t in node.PerThreadStats.Where(t => t.ActualCPUMs > 0)) + AddPropertyRow($" Thread {t.ThreadId}", $"{t.ActualCPUMs:N0} ms", indent: true); + } + if (node.UdfElapsedTimeMs > 0) + AddPropertyRow("UDF Elapsed", $"{node.UdfElapsedTimeMs:N0} ms"); + if (node.UdfCpuTimeMs > 0) + AddPropertyRow("UDF CPU", $"{node.UdfCpuTimeMs:N0} ms"); + } + + // I/O + var hasIo = node.ActualLogicalReads > 0 || node.ActualPhysicalReads > 0 + || node.ActualScans > 0 || node.ActualReadAheads > 0 + || node.ActualSegmentReads > 0 || node.ActualSegmentSkips > 0; + if (hasIo) + { + AddPropertySection("Actual I/O"); + AddPropertyRow("Logical Reads", $"{node.ActualLogicalReads:N0}"); + if (node.PerThreadStats.Count > 1) + foreach (var t in node.PerThreadStats.Where(t => t.ActualLogicalReads > 0)) + AddPropertyRow($" Thread {t.ThreadId}", $"{t.ActualLogicalReads:N0}", indent: true); + if (node.ActualPhysicalReads > 0) + { + AddPropertyRow("Physical Reads", $"{node.ActualPhysicalReads:N0}"); + if (node.PerThreadStats.Count > 1) + foreach (var t in node.PerThreadStats.Where(t => t.ActualPhysicalReads > 0)) + AddPropertyRow($" Thread {t.ThreadId}", $"{t.ActualPhysicalReads:N0}", indent: true); + } + if (node.ActualScans > 0) + { + AddPropertyRow("Scans", $"{node.ActualScans:N0}"); + if (node.PerThreadStats.Count > 1) + foreach (var t in node.PerThreadStats.Where(t => t.ActualScans > 0)) + AddPropertyRow($" Thread {t.ThreadId}", $"{t.ActualScans:N0}", indent: true); + } + if (node.ActualReadAheads > 0) + { + AddPropertyRow("Read-Ahead Reads", $"{node.ActualReadAheads:N0}"); + if (node.PerThreadStats.Count > 1) + foreach (var t in node.PerThreadStats.Where(t => t.ActualReadAheads > 0)) + AddPropertyRow($" Thread {t.ThreadId}", $"{t.ActualReadAheads:N0}", indent: true); + } + if (node.ActualSegmentReads > 0) + AddPropertyRow("Segment Reads", $"{node.ActualSegmentReads:N0}"); + if (node.ActualSegmentSkips > 0) + AddPropertyRow("Segment Skips", $"{node.ActualSegmentSkips:N0}"); + } + + // LOB I/O + var hasLobIo = node.ActualLobLogicalReads > 0 || node.ActualLobPhysicalReads > 0 + || node.ActualLobReadAheads > 0; + if (hasLobIo) + { + AddPropertySection("Actual LOB I/O"); + if (node.ActualLobLogicalReads > 0) + AddPropertyRow("LOB Logical Reads", $"{node.ActualLobLogicalReads:N0}"); + if (node.ActualLobPhysicalReads > 0) + AddPropertyRow("LOB Physical Reads", $"{node.ActualLobPhysicalReads:N0}"); + if (node.ActualLobReadAheads > 0) + AddPropertyRow("LOB Read-Aheads", $"{node.ActualLobReadAheads:N0}"); + } + } + + // === Predicates Section === + var hasPredicates = !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) + || node.GuessedSelectivity; + if (hasPredicates) + { + AddPropertySection("Predicates"); + if (!string.IsNullOrEmpty(node.SeekPredicates)) + AddPropertyRow("Seek Predicate", node.SeekPredicates, isCode: true); + if (!string.IsNullOrEmpty(node.Predicate)) + AddPropertyRow("Predicate", node.Predicate, isCode: true); + if (!string.IsNullOrEmpty(node.HashKeysBuild)) + AddPropertyRow("Hash Keys (Build)", node.HashKeysBuild, isCode: true); + if (!string.IsNullOrEmpty(node.HashKeysProbe)) + AddPropertyRow("Hash Keys (Probe)", node.HashKeysProbe, isCode: true); + if (!string.IsNullOrEmpty(node.BuildResidual)) + AddPropertyRow("Build Residual", node.BuildResidual, isCode: true); + if (!string.IsNullOrEmpty(node.ProbeResidual)) + AddPropertyRow("Probe Residual", node.ProbeResidual, isCode: true); + if (!string.IsNullOrEmpty(node.MergeResidual)) + AddPropertyRow("Merge Residual", node.MergeResidual, isCode: true); + if (!string.IsNullOrEmpty(node.PassThru)) + AddPropertyRow("Pass Through", node.PassThru, isCode: true); + if (!string.IsNullOrEmpty(node.SetPredicate)) + AddPropertyRow("Set Predicate", node.SetPredicate, isCode: true); + if (node.GuessedSelectivity) + AddPropertyRow("Guessed Selectivity", "True (optimizer guessed, no statistics)"); + } + + // === Output Columns === + if (!string.IsNullOrEmpty(node.OutputColumns)) + { + AddPropertySection("Output"); + AddPropertyRow("Columns", node.OutputColumns, isCode: true); + } + + // === Memory === + if (node.MemoryGrantKB > 0 || node.DesiredMemoryKB > 0 || node.MaxUsedMemoryKB > 0 + || node.MemoryFractionInput > 0 || node.MemoryFractionOutput > 0 + || node.InputMemoryGrantKB > 0 || node.OutputMemoryGrantKB > 0 || node.UsedMemoryGrantKB > 0) + { + AddPropertySection("Memory"); + if (node.MemoryGrantKB > 0) AddPropertyRow("Granted", $"{node.MemoryGrantKB:N0} KB"); + if (node.DesiredMemoryKB > 0) AddPropertyRow("Desired", $"{node.DesiredMemoryKB:N0} KB"); + if (node.MaxUsedMemoryKB > 0) AddPropertyRow("Max Used", $"{node.MaxUsedMemoryKB:N0} KB"); + if (node.InputMemoryGrantKB > 0) AddPropertyRow("Input Grant", $"{node.InputMemoryGrantKB:N0} KB"); + if (node.OutputMemoryGrantKB > 0) AddPropertyRow("Output Grant", $"{node.OutputMemoryGrantKB:N0} KB"); + if (node.UsedMemoryGrantKB > 0) AddPropertyRow("Used Grant", $"{node.UsedMemoryGrantKB:N0} KB"); + if (node.MemoryFractionInput > 0) AddPropertyRow("Fraction Input", $"{node.MemoryFractionInput:F4}"); + if (node.MemoryFractionOutput > 0) AddPropertyRow("Fraction Output", $"{node.MemoryFractionOutput:F4}"); + } + + // === Root node only: statement-level sections === + if (node.Parent == null && _currentStatement != null) + { + var s = _currentStatement; + + // === Statement Text === + if (!string.IsNullOrEmpty(s.StatementText) || !string.IsNullOrEmpty(s.StmtUseDatabaseName)) + { + AddPropertySection("Statement"); + if (!string.IsNullOrEmpty(s.StatementText)) + AddPropertyRow("Text", s.StatementText, isCode: true); + if (!string.IsNullOrEmpty(s.ParameterizedText) && s.ParameterizedText != s.StatementText) + AddPropertyRow("Parameterized", s.ParameterizedText, isCode: true); + if (!string.IsNullOrEmpty(s.StmtUseDatabaseName)) + AddPropertyRow("USE Database", s.StmtUseDatabaseName); + } + + // === Cursor Info === + if (!string.IsNullOrEmpty(s.CursorName)) + { + AddPropertySection("Cursor Info"); + AddPropertyRow("Cursor Name", s.CursorName); + if (!string.IsNullOrEmpty(s.CursorActualType)) + AddPropertyRow("Actual Type", s.CursorActualType); + if (!string.IsNullOrEmpty(s.CursorRequestedType)) + AddPropertyRow("Requested Type", s.CursorRequestedType); + if (!string.IsNullOrEmpty(s.CursorConcurrency)) + AddPropertyRow("Concurrency", s.CursorConcurrency); + AddPropertyRow("Forward Only", s.CursorForwardOnly ? "True" : "False"); + } + + // === Statement Memory Grant === + if (s.MemoryGrant != null) + { + var mg = s.MemoryGrant; + AddPropertySection("Memory Grant Info"); + AddPropertyRow("Granted", $"{mg.GrantedMemoryKB:N0} KB"); + AddPropertyRow("Max Used", $"{mg.MaxUsedMemoryKB:N0} KB"); + AddPropertyRow("Requested", $"{mg.RequestedMemoryKB:N0} KB"); + AddPropertyRow("Desired", $"{mg.DesiredMemoryKB:N0} KB"); + AddPropertyRow("Required", $"{mg.RequiredMemoryKB:N0} KB"); + AddPropertyRow("Serial Required", $"{mg.SerialRequiredMemoryKB:N0} KB"); + AddPropertyRow("Serial Desired", $"{mg.SerialDesiredMemoryKB:N0} KB"); + if (mg.GrantWaitTimeMs > 0) + AddPropertyRow("Grant Wait Time", $"{mg.GrantWaitTimeMs:N0} ms"); + if (mg.LastRequestedMemoryKB > 0) + AddPropertyRow("Last Requested", $"{mg.LastRequestedMemoryKB:N0} KB"); + if (!string.IsNullOrEmpty(mg.IsMemoryGrantFeedbackAdjusted)) + AddPropertyRow("Feedback Adjusted", mg.IsMemoryGrantFeedbackAdjusted); + } + + // === Statement Info === + AddPropertySection("Statement Info"); + if (!string.IsNullOrEmpty(s.StatementOptmLevel)) + AddPropertyRow("Optimization Level", s.StatementOptmLevel); + if (!string.IsNullOrEmpty(s.StatementOptmEarlyAbortReason)) + AddPropertyRow("Early Abort Reason", s.StatementOptmEarlyAbortReason); + if (s.CardinalityEstimationModelVersion > 0) + AddPropertyRow("CE Model Version", $"{s.CardinalityEstimationModelVersion}"); + if (s.DegreeOfParallelism > 0) + AddPropertyRow("DOP", $"{s.DegreeOfParallelism}"); + if (s.EffectiveDOP > 0) + AddPropertyRow("Effective DOP", $"{s.EffectiveDOP}"); + if (!string.IsNullOrEmpty(s.DOPFeedbackAdjusted)) + AddPropertyRow("DOP Feedback", s.DOPFeedbackAdjusted); + if (!string.IsNullOrEmpty(s.NonParallelPlanReason)) + AddPropertyRow("Non-Parallel Reason", s.NonParallelPlanReason); + if (s.MaxQueryMemoryKB > 0) + AddPropertyRow("Max Query Memory", $"{s.MaxQueryMemoryKB:N0} KB"); + if (s.QueryPlanMemoryGrantKB > 0) + AddPropertyRow("QueryPlan Memory Grant", $"{s.QueryPlanMemoryGrantKB:N0} KB"); + AddPropertyRow("Compile Time", $"{s.CompileTimeMs:N0} ms"); + AddPropertyRow("Compile CPU", $"{s.CompileCPUMs:N0} ms"); + AddPropertyRow("Compile Memory", $"{s.CompileMemoryKB:N0} KB"); + if (s.CachedPlanSizeKB > 0) + AddPropertyRow("Cached Plan Size", $"{s.CachedPlanSizeKB:N0} KB"); + AddPropertyRow("Retrieved From Cache", s.RetrievedFromCache ? "True" : "False"); + AddPropertyRow("Batch Mode On RowStore", s.BatchModeOnRowStoreUsed ? "True" : "False"); + AddPropertyRow("Security Policy", s.SecurityPolicyApplied ? "True" : "False"); + AddPropertyRow("Parameterization Type", $"{s.StatementParameterizationType}"); + if (!string.IsNullOrEmpty(s.QueryHash)) + AddPropertyRow("Query Hash", s.QueryHash, isCode: true); + if (!string.IsNullOrEmpty(s.QueryPlanHash)) + AddPropertyRow("Plan Hash", s.QueryPlanHash, isCode: true); + if (!string.IsNullOrEmpty(s.StatementSqlHandle)) + AddPropertyRow("SQL Handle", s.StatementSqlHandle, isCode: true); + AddPropertyRow("DB Settings Id", $"{s.DatabaseContextSettingsId}"); + AddPropertyRow("Parent Object Id", $"{s.ParentObjectId}"); + + // Plan Guide + if (!string.IsNullOrEmpty(s.PlanGuideName)) + { + AddPropertyRow("Plan Guide", s.PlanGuideName); + if (!string.IsNullOrEmpty(s.PlanGuideDB)) + AddPropertyRow("Plan Guide DB", s.PlanGuideDB); + } + if (s.UsePlan) + AddPropertyRow("USE PLAN", "True"); + + // Query Store Hints + if (s.QueryStoreStatementHintId > 0) + { + AddPropertyRow("QS Hint Id", $"{s.QueryStoreStatementHintId}"); + if (!string.IsNullOrEmpty(s.QueryStoreStatementHintText)) + AddPropertyRow("QS Hint", s.QueryStoreStatementHintText, isCode: true); + if (!string.IsNullOrEmpty(s.QueryStoreStatementHintSource)) + AddPropertyRow("QS Hint Source", s.QueryStoreStatementHintSource); + } + + // === Feature Flags === + if (s.ContainsInterleavedExecutionCandidates || s.ContainsInlineScalarTsqlUdfs + || s.ContainsLedgerTables || s.ExclusiveProfileTimeActive || s.QueryCompilationReplay > 0 + || s.QueryVariantID > 0) + { + AddPropertySection("Feature Flags"); + if (s.ContainsInterleavedExecutionCandidates) + AddPropertyRow("Interleaved Execution", "True"); + if (s.ContainsInlineScalarTsqlUdfs) + AddPropertyRow("Inline Scalar UDFs", "True"); + if (s.ContainsLedgerTables) + AddPropertyRow("Ledger Tables", "True"); + if (s.ExclusiveProfileTimeActive) + AddPropertyRow("Exclusive Profile Time", "True"); + if (s.QueryCompilationReplay > 0) + AddPropertyRow("Compilation Replay", $"{s.QueryCompilationReplay}"); + if (s.QueryVariantID > 0) + AddPropertyRow("Query Variant ID", $"{s.QueryVariantID}"); + } + + // === PSP Dispatcher === + if (s.Dispatcher != null) + { + AddPropertySection("PSP Dispatcher"); + if (!string.IsNullOrEmpty(s.DispatcherPlanHandle)) + AddPropertyRow("Plan Handle", s.DispatcherPlanHandle, isCode: true); + foreach (var psp in s.Dispatcher.ParameterSensitivePredicates) + { + var range = $"[{psp.LowBoundary:N0} — {psp.HighBoundary:N0}]"; + var predText = psp.PredicateText ?? ""; + AddPropertyRow("Predicate", $"{predText} {range}", isCode: true); + foreach (var stat in psp.Statistics) + { + var statLabel = !string.IsNullOrEmpty(stat.TableName) + ? $" {stat.TableName}.{stat.StatisticsName}" + : $" {stat.StatisticsName}"; + AddPropertyRow(statLabel, $"Modified: {stat.ModificationCount:N0}, Sampled: {stat.SamplingPercent:F1}%", indent: true); + } + } + foreach (var opt in s.Dispatcher.OptionalParameterPredicates) + { + if (!string.IsNullOrEmpty(opt.PredicateText)) + AddPropertyRow("Optional Predicate", opt.PredicateText, isCode: true); + } + } + + // === Cardinality Feedback === + if (s.CardinalityFeedback.Count > 0) + { + AddPropertySection("Cardinality Feedback"); + foreach (var cf in s.CardinalityFeedback) + AddPropertyRow($"Node {cf.Key}", $"{cf.Value:N0}"); + } + + // === Optimization Replay === + if (!string.IsNullOrEmpty(s.OptimizationReplayScript)) + { + AddPropertySection("Optimization Replay"); + AddPropertyRow("Script", s.OptimizationReplayScript, isCode: true); + } + + // === Template Plan Guide === + if (!string.IsNullOrEmpty(s.TemplatePlanGuideName)) + { + AddPropertyRow("Template Plan Guide", s.TemplatePlanGuideName); + if (!string.IsNullOrEmpty(s.TemplatePlanGuideDB)) + AddPropertyRow("Template Guide DB", s.TemplatePlanGuideDB); + } + + // === Handles === + if (!string.IsNullOrEmpty(s.ParameterizedPlanHandle) || !string.IsNullOrEmpty(s.BatchSqlHandle)) + { + AddPropertySection("Handles"); + if (!string.IsNullOrEmpty(s.ParameterizedPlanHandle)) + AddPropertyRow("Parameterized Plan", s.ParameterizedPlanHandle, isCode: true); + if (!string.IsNullOrEmpty(s.BatchSqlHandle)) + AddPropertyRow("Batch SQL Handle", s.BatchSqlHandle, isCode: true); + } + + // === Set Options === + if (s.SetOptions != null) + { + var so = s.SetOptions; + AddPropertySection("Set Options"); + AddPropertyRow("ANSI_NULLS", so.AnsiNulls ? "True" : "False"); + AddPropertyRow("ANSI_PADDING", so.AnsiPadding ? "True" : "False"); + AddPropertyRow("ANSI_WARNINGS", so.AnsiWarnings ? "True" : "False"); + AddPropertyRow("ARITHABORT", so.ArithAbort ? "True" : "False"); + AddPropertyRow("CONCAT_NULL", so.ConcatNullYieldsNull ? "True" : "False"); + AddPropertyRow("NUMERIC_ROUNDABORT", so.NumericRoundAbort ? "True" : "False"); + AddPropertyRow("QUOTED_IDENTIFIER", so.QuotedIdentifier ? "True" : "False"); + } + + // === Optimizer Hardware Properties === + if (s.HardwareProperties != null) + { + var hw = s.HardwareProperties; + AddPropertySection("Hardware Properties"); + AddPropertyRow("Available Memory", $"{hw.EstimatedAvailableMemoryGrant:N0} KB"); + AddPropertyRow("Pages Cached", $"{hw.EstimatedPagesCached:N0}"); + AddPropertyRow("Available DOP", $"{hw.EstimatedAvailableDOP}"); + if (hw.MaxCompileMemory > 0) + AddPropertyRow("Max Compile Memory", $"{hw.MaxCompileMemory:N0} KB"); + } + + // === Plan Version === + if (_currentPlan != null && (!string.IsNullOrEmpty(_currentPlan.BuildVersion) || !string.IsNullOrEmpty(_currentPlan.Build))) + { + AddPropertySection("Plan Version"); + if (!string.IsNullOrEmpty(_currentPlan.BuildVersion)) + AddPropertyRow("Build Version", _currentPlan.BuildVersion); + if (!string.IsNullOrEmpty(_currentPlan.Build)) + AddPropertyRow("Build", _currentPlan.Build); + if (_currentPlan.ClusteredMode) + AddPropertyRow("Clustered Mode", "True"); + } + + // === Optimizer Stats Usage === + if (s.StatsUsage.Count > 0) + { + AddPropertySection("Statistics Used"); + foreach (var stat in s.StatsUsage) + { + var statLabel = !string.IsNullOrEmpty(stat.TableName) + ? $"{stat.TableName}.{stat.StatisticsName}" + : stat.StatisticsName; + var statDetail = $"Modified: {stat.ModificationCount:N0}, Sampled: {stat.SamplingPercent:F1}%"; + if (!string.IsNullOrEmpty(stat.LastUpdate)) + statDetail += $", Updated: {stat.LastUpdate}"; + AddPropertyRow(statLabel, statDetail); + } + } + + // === Parameters === + if (s.Parameters.Count > 0) + { + AddPropertySection("Parameters"); + foreach (var p in s.Parameters) + { + var paramText = p.DataType; + if (!string.IsNullOrEmpty(p.CompiledValue)) + paramText += $", Compiled: {p.CompiledValue}"; + if (!string.IsNullOrEmpty(p.RuntimeValue)) + paramText += $", Runtime: {p.RuntimeValue}"; + AddPropertyRow(p.Name, paramText); + } + } + + // === Query Time Stats (actual plans) === + if (s.QueryTimeStats != null) + { + AddPropertySection("Query Time Stats"); + AddPropertyRow("CPU Time", $"{s.QueryTimeStats.CpuTimeMs:N0} ms"); + AddPropertyRow("Elapsed Time", $"{s.QueryTimeStats.ElapsedTimeMs:N0} ms"); + if (s.QueryUdfCpuTimeMs > 0) + AddPropertyRow("UDF CPU Time", $"{s.QueryUdfCpuTimeMs:N0} ms"); + if (s.QueryUdfElapsedTimeMs > 0) + AddPropertyRow("UDF Elapsed Time", $"{s.QueryUdfElapsedTimeMs:N0} ms"); + } + + // === Thread Stats (actual plans) === + if (s.ThreadStats != null) + { + AddPropertySection("Thread Stats"); + AddPropertyRow("Branches", $"{s.ThreadStats.Branches}"); + AddPropertyRow("Used Threads", $"{s.ThreadStats.UsedThreads}"); + var totalReserved = s.ThreadStats.Reservations.Sum(r => r.ReservedThreads); + if (totalReserved > 0) + { + AddPropertyRow("Reserved Threads", $"{totalReserved}"); + if (totalReserved > s.ThreadStats.UsedThreads) + AddPropertyRow("Inactive Threads", $"{totalReserved - s.ThreadStats.UsedThreads}"); + } + foreach (var res in s.ThreadStats.Reservations) + AddPropertyRow($" Node {res.NodeId}", $"{res.ReservedThreads} reserved"); + } + + // === Wait Stats (actual plans) === + if (s.WaitStats.Count > 0) + { + AddPropertySection("Wait Stats"); + foreach (var w in s.WaitStats.OrderByDescending(w => w.WaitTimeMs)) + AddPropertyRow(w.WaitType, $"{w.WaitTimeMs:N0} ms ({w.WaitCount:N0} waits)"); + } + + // === Trace Flags === + if (s.TraceFlags.Count > 0) + { + AddPropertySection("Trace Flags"); + foreach (var tf in s.TraceFlags) + { + var tfLabel = $"TF {tf.Value}"; + var tfDetail = $"{tf.Scope}{(tf.IsCompileTime ? ", Compile-time" : ", Runtime")}"; + AddPropertyRow(tfLabel, tfDetail); + } + } + + // === Indexed Views === + if (s.IndexedViews.Count > 0) + { + AddPropertySection("Indexed Views"); + foreach (var iv in s.IndexedViews) + AddPropertyRow("View", iv, isCode: true); + } + + // === Plan-Level Warnings === + if (s.PlanWarnings.Count > 0) + { + var planWarningsPanel = new StackPanel(); + var sortedPlanWarnings = s.PlanWarnings + .OrderByDescending(w => w.MaxBenefitPercent ?? -1) + .ThenByDescending(w => w.Severity) + .ThenBy(w => w.WarningType); + foreach (var w in sortedPlanWarnings) + { + var warnColor = w.Severity == PlanWarningSeverity.Critical ? "#E57373" + : w.Severity == PlanWarningSeverity.Warning ? "#FFB347" : "#6BB5FF"; + var warnPanel = new StackPanel { Margin = new Thickness(10, 2, 10, 2) }; + var legacyTag = w.IsLegacy ? " [legacy]" : ""; + var planWarnHeader = w.MaxBenefitPercent.HasValue + ? $"\u26A0 {w.WarningType}{legacyTag} \u2014 up to {FormatBenefitPercent(w.MaxBenefitPercent.Value)}% benefit" + : $"\u26A0 {w.WarningType}{legacyTag}"; + warnPanel.Children.Add(new TextBlock + { + Text = planWarnHeader, + FontWeight = FontWeight.SemiBold, + FontSize = 11, + Foreground = new SolidColorBrush(Color.Parse(warnColor)) + }); + warnPanel.Children.Add(new TextBlock + { + Text = w.Message, + FontSize = 11, + Foreground = TooltipFgBrush, + TextWrapping = TextWrapping.Wrap, + Margin = new Thickness(16, 0, 0, 0) + }); + if (!string.IsNullOrEmpty(w.ActionableFix)) + { + warnPanel.Children.Add(new TextBlock + { + Text = w.ActionableFix, + FontSize = 11, + FontStyle = FontStyle.Italic, + Foreground = TooltipFgBrush, + TextWrapping = TextWrapping.Wrap, + Margin = new Thickness(16, 2, 0, 0) + }); + } + planWarningsPanel.Children.Add(warnPanel); + } + + var planWarningsExpander = new Expander + { + IsExpanded = true, + Header = new TextBlock + { + Text = "Plan Warnings", + FontWeight = FontWeight.SemiBold, + FontSize = 11, + Foreground = SectionHeaderBrush + }, + Content = planWarningsPanel, + Margin = new Thickness(0, 2, 0, 0), + Padding = new Thickness(0), + Foreground = SectionHeaderBrush, + Background = new SolidColorBrush(Color.FromArgb(0x18, 0x4F, 0xA3, 0xFF)), + BorderBrush = PropSeparatorBrush, + BorderThickness = new Thickness(0, 0, 0, 1), + HorizontalAlignment = HorizontalAlignment.Stretch, + HorizontalContentAlignment = HorizontalAlignment.Stretch + }; + PropertiesContent.Children.Add(planWarningsExpander); + } + + // === Missing Indexes === + if (s.MissingIndexes.Count > 0) + { + AddPropertySection("Missing Indexes"); + foreach (var mi in s.MissingIndexes) + { + AddPropertyRow($"{mi.Schema}.{mi.Table}", $"Impact: {mi.Impact:F1}%"); + if (!string.IsNullOrEmpty(mi.CreateStatement)) + AddPropertyRow("CREATE INDEX", mi.CreateStatement, isCode: true); + } + } + } + + // === Warnings === + if (node.HasWarnings) + { + var warningsPanel = new StackPanel(); + var sortedNodeWarnings = node.Warnings + .OrderByDescending(w => w.MaxBenefitPercent ?? -1) + .ThenByDescending(w => w.Severity) + .ThenBy(w => w.WarningType); + foreach (var w in sortedNodeWarnings) + { + var warnColor = w.Severity == PlanWarningSeverity.Critical ? "#E57373" + : w.Severity == PlanWarningSeverity.Warning ? "#FFB347" : "#6BB5FF"; + var warnPanel = new StackPanel { Margin = new Thickness(10, 2, 10, 2) }; + var nodeLegacyTag = w.IsLegacy ? " [legacy]" : ""; + var nodeWarnHeader = w.MaxBenefitPercent.HasValue + ? $"\u26A0 {w.WarningType}{nodeLegacyTag} \u2014 up to {FormatBenefitPercent(w.MaxBenefitPercent.Value)}% benefit" + : $"\u26A0 {w.WarningType}{nodeLegacyTag}"; + warnPanel.Children.Add(new TextBlock + { + Text = nodeWarnHeader, + FontWeight = FontWeight.SemiBold, + FontSize = 11, + Foreground = new SolidColorBrush(Color.Parse(warnColor)) + }); + warnPanel.Children.Add(new TextBlock + { + Text = w.Message, + FontSize = 11, + Foreground = TooltipFgBrush, + TextWrapping = TextWrapping.Wrap, + Margin = new Thickness(16, 0, 0, 0) + }); + warningsPanel.Children.Add(warnPanel); + } + + var warningsExpander = new Expander + { + IsExpanded = true, + Header = new TextBlock + { + Text = "Warnings", + FontWeight = FontWeight.SemiBold, + FontSize = 11, + Foreground = SectionHeaderBrush + }, + Content = warningsPanel, + Margin = new Thickness(0, 2, 0, 0), + Padding = new Thickness(0), + Foreground = SectionHeaderBrush, + Background = new SolidColorBrush(Color.FromArgb(0x18, 0x4F, 0xA3, 0xFF)), + BorderBrush = PropSeparatorBrush, + BorderThickness = new Thickness(0, 0, 0, 1), + HorizontalAlignment = HorizontalAlignment.Stretch, + HorizontalContentAlignment = HorizontalAlignment.Stretch + }; + PropertiesContent.Children.Add(warningsExpander); + } + + // Show the panel + _propertiesColumn.Width = new GridLength(320); + _splitterColumn.Width = new GridLength(5); + PropertiesSplitter.IsVisible = true; + PropertiesPanel.IsVisible = true; + } + + private void AddPropertySection(string title) + { + var labelCol = new ColumnDefinition { Width = new GridLength(_propertyLabelWidth) }; + _sectionLabelColumns.Add(labelCol); + + // Sync column widths across sections when user drags the GridSplitter + labelCol.PropertyChanged += (_, args) => + { + if (args.Property.Name != "Width" || _isSyncingColumnWidth) return; + _isSyncingColumnWidth = true; + _propertyLabelWidth = labelCol.Width.Value; + foreach (var col in _sectionLabelColumns) + { + if (col != labelCol) + col.Width = labelCol.Width; + } + _isSyncingColumnWidth = false; + }; + + var sectionGrid = new Grid + { + Margin = new Thickness(6, 0, 6, 0) + }; + sectionGrid.ColumnDefinitions.Add(labelCol); + sectionGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(4) }); + sectionGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + + _currentSectionGrid = sectionGrid; + _currentSectionRowIndex = 0; + + var expander = new Expander + { + IsExpanded = true, + Header = new TextBlock + { + Text = title, + FontWeight = FontWeight.SemiBold, + FontSize = 11, + Foreground = SectionHeaderBrush + }, + Content = sectionGrid, + Margin = new Thickness(0, 2, 0, 0), + Padding = new Thickness(0), + Foreground = SectionHeaderBrush, + Background = new SolidColorBrush(Color.FromArgb(0x18, 0x4F, 0xA3, 0xFF)), + BorderBrush = PropSeparatorBrush, + BorderThickness = new Thickness(0, 0, 0, 1), + HorizontalAlignment = HorizontalAlignment.Stretch, + HorizontalContentAlignment = HorizontalAlignment.Stretch + }; + PropertiesContent.Children.Add(expander); + } + + private void AddPropertyRow(string label, string value, bool isCode = false, bool indent = false) + { + if (_currentSectionGrid == null) return; + + var row = _currentSectionRowIndex++; + _currentSectionGrid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); + + var labelBlock = new TextBlock + { + Text = label, + FontSize = indent ? 10 : 11, + Foreground = TooltipFgBrush, + VerticalAlignment = VerticalAlignment.Top, + TextWrapping = TextWrapping.Wrap, + Margin = new Thickness(indent ? 16 : 4, 2, 0, 2) + }; + Grid.SetColumn(labelBlock, 0); + Grid.SetRow(labelBlock, row); + _currentSectionGrid.Children.Add(labelBlock); + + // GridSplitter in column 1 (only in first row per section) + if (row == 0) + { + var splitter = new GridSplitter + { + Width = 4, + Background = Brushes.Transparent, + Foreground = Brushes.Transparent, + BorderThickness = new Thickness(0), + Cursor = new Avalonia.Input.Cursor(Avalonia.Input.StandardCursorType.SizeWestEast) + }; + Grid.SetColumn(splitter, 1); + Grid.SetRow(splitter, 0); + Grid.SetRowSpan(splitter, 100); // span all rows + _currentSectionGrid.Children.Add(splitter); + } + + var valueBox = new TextBox + { + Text = value, + FontSize = indent ? 10 : 11, + Foreground = TooltipFgBrush, + TextWrapping = TextWrapping.Wrap, + IsReadOnly = true, + BorderThickness = new Thickness(0), + Background = Brushes.Transparent, + Padding = new Thickness(0), + Margin = new Thickness(0, 2, 4, 2), + VerticalAlignment = VerticalAlignment.Top + }; + if (isCode) valueBox.FontFamily = new FontFamily("Consolas"); + Grid.SetColumn(valueBox, 2); + Grid.SetRow(valueBox, row); + _currentSectionGrid.Children.Add(valueBox); + } + + private void CloseProperties_Click(object? sender, RoutedEventArgs e) + { + ClosePropertiesPanel(); + } + + private void ClosePropertiesPanel() + { + PropertiesPanel.IsVisible = false; + PropertiesSplitter.IsVisible = false; + _propertiesColumn.Width = new GridLength(0); + _splitterColumn.Width = new GridLength(0); + + // Deselect node + if (_selectedNodeBorder != null) + { + _selectedNodeBorder.BorderBrush = _selectedNodeOriginalBorder; + _selectedNodeBorder.BorderThickness = _selectedNodeOriginalThickness; + _selectedNodeBorder = null; + } + } + + 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); + } + + /// + /// 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(); + + 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.Rendering.cs b/src/PlanViewer.App/Controls/PlanViewerControl.Rendering.cs new file mode 100644 index 0000000..9956c40 --- /dev/null +++ b/src/PlanViewer.App/Controls/PlanViewerControl.Rendering.cs @@ -0,0 +1,550 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Shapes; +using Avalonia.Input; +using Avalonia.Layout; +using Avalonia.Media; +using PlanViewer.App.Helpers; +using PlanViewer.App.Services; +using PlanViewer.Core.Models; +using PlanViewer.Core.Services; +using AvaloniaPath = Avalonia.Controls.Shapes.Path; + +namespace PlanViewer.App.Controls; + +public partial class PlanViewerControl : UserControl +{ + private static void CountNodeWarnings(PlanNode node, ref int total, ref int critical) + { + total += node.Warnings.Count; + critical += node.Warnings.Count(w => w.Severity == PlanWarningSeverity.Critical); + foreach (var child in node.Children) + CountNodeWarnings(child, ref total, ref critical); + } + + private void RenderStatement(PlanStatement statement) + { + _currentStatement = statement; + PlanCanvas.Children.Clear(); + _nodeBorderMap.Clear(); + _selectedNodeBorder = null; + _selectedNode = null; + + if (statement.RootNode == null) return; + + // Layout + PlanLayoutEngine.Layout(statement); + var (width, height) = PlanLayoutEngine.GetExtents(statement.RootNode); + PlanCanvas.Width = width; + PlanCanvas.Height = height; + + // Render edges first (behind nodes) + var divergenceLimit = Math.Max(2.0, AppSettingsService.Load().AccuracyRatioDivergenceLimit); + RenderEdges(statement.RootNode, divergenceLimit); + + // Render nodes — pass total warning count to root node for badge + var allWarnings = new List(); + CollectWarnings(statement.RootNode, allWarnings); + RenderNodes(statement.RootNode, divergenceLimit, allWarnings.Count); + + // Update banners + ShowMissingIndexes(statement.MissingIndexes); + ShowParameters(statement); + ShowWaitStats(statement.WaitStats, statement.WaitBenefits, statement.QueryTimeStats != null); + ShowRuntimeSummary(statement); + UpdateInsightsHeader(); + + // Scroll to top-left so the plan root is immediately visible + PlanScrollViewer.Offset = new Avalonia.Vector(0, 0); + + // Canvas-level context menu (zoom, advice, repro, save) + // Set on ScrollViewer, not Canvas — Canvas has no background so it's not hit-testable + PlanScrollViewer.ContextMenu = BuildCanvasContextMenu(); + + CostText.Text = ""; + + // Update minimap if visible + if (MinimapPanel.IsVisible) + Avalonia.Threading.Dispatcher.UIThread.Post(RenderMinimap, Avalonia.Threading.DispatcherPriority.Loaded); + } + + private void RenderNodes(PlanNode node, double divergenceLimit, int totalWarningCount = -1) + { + var visual = CreateNodeVisual(node, divergenceLimit, totalWarningCount); + Canvas.SetLeft(visual, node.X); + Canvas.SetTop(visual, node.Y); + PlanCanvas.Children.Add(visual); + + foreach (var child in node.Children) + RenderNodes(child, divergenceLimit); + } + + private Border CreateNodeVisual(PlanNode node, double divergenceLimit, int totalWarningCount = -1) + { + var isExpensive = node.IsExpensive; + + var bgBrush = isExpensive + ? new SolidColorBrush(Color.FromArgb(0x30, 0xE5, 0x73, 0x73)) + : FindBrushResource("BackgroundLightBrush"); + + var borderBrush = isExpensive + ? OrangeRedBrush + : FindBrushResource("BorderBrush"); + + var border = new Border + { + Width = PlanLayoutEngine.NodeWidth, + MinHeight = PlanLayoutEngine.NodeHeightMin, + Background = bgBrush, + BorderBrush = borderBrush, + BorderThickness = new Thickness(isExpensive ? 2 : 1), + CornerRadius = new CornerRadius(4), + Padding = new Thickness(6, 4, 6, 4), + Cursor = new Cursor(StandardCursorType.Hand) + }; + + // Map border to node (replaces WPF Tag) + _nodeBorderMap[border] = node; + + // Tooltip — root node gets all collected warnings so the tooltip shows them + if (totalWarningCount > 0) + { + var allWarnings = new List(); + if (_currentStatement != null) + allWarnings.AddRange(_currentStatement.PlanWarnings); + CollectWarnings(node, allWarnings); + ToolTip.SetTip(border, BuildNodeTooltipContent(node, allWarnings)); + } + else + { + ToolTip.SetTip(border, BuildNodeTooltipContent(node)); + } + + // Click to select + show properties + border.PointerPressed += Node_Click; + + // Right-click context menu + border.ContextMenu = BuildNodeContextMenu(node); + + var stack = new StackPanel { HorizontalAlignment = HorizontalAlignment.Center }; + + // Icon row: icon + optional warning/parallel indicators + var iconRow = new StackPanel + { + Orientation = Orientation.Horizontal, + HorizontalAlignment = HorizontalAlignment.Center + }; + + var iconBitmap = IconHelper.LoadIcon(node.IconName); + if (iconBitmap != null) + { + iconRow.Children.Add(new Image + { + Source = iconBitmap, + Width = 32, + Height = 32, + Margin = new Thickness(0, 0, 0, 2) + }); + } + + // Warning indicator badge (orange triangle with !) + if (node.HasWarnings) + { + var warnBadge = new Grid + { + Width = 20, Height = 20, + Margin = new Thickness(4, 0, 0, 0), + VerticalAlignment = VerticalAlignment.Center + }; + warnBadge.Children.Add(new AvaloniaPath + { + Data = StreamGeometry.Parse("M 10,0 L 20,18 L 0,18 Z"), + Fill = OrangeBrush + }); + warnBadge.Children.Add(new TextBlock + { + Text = "!", + FontSize = 12, + FontWeight = FontWeight.ExtraBold, + Foreground = Brushes.White, + HorizontalAlignment = HorizontalAlignment.Center, + Margin = new Thickness(0, 3, 0, 0) + }); + iconRow.Children.Add(warnBadge); + } + + // Parallel indicator badge (amber circle with arrows) + if (node.Parallel) + { + var parBadge = new Grid + { + Width = 20, Height = 20, + Margin = new Thickness(4, 0, 0, 0), + VerticalAlignment = VerticalAlignment.Center + }; + parBadge.Children.Add(new Ellipse + { + Width = 20, Height = 20, + Fill = new SolidColorBrush(Color.FromRgb(0xFF, 0xC1, 0x07)) + }); + parBadge.Children.Add(new TextBlock + { + Text = "\u21C6", + FontSize = 12, + FontWeight = FontWeight.Bold, + Foreground = new SolidColorBrush(Color.FromRgb(0x33, 0x33, 0x33)), + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center + }); + iconRow.Children.Add(parBadge); + } + + // Nonclustered index count badge (modification operators maintaining multiple NC indexes) + if (node.NonClusteredIndexCount > 0) + { + var ncBadge = new Border + { + Background = new SolidColorBrush(Color.FromRgb(0x6C, 0x75, 0x7D)), + CornerRadius = new CornerRadius(4), + Padding = new Thickness(4, 1), + Margin = new Thickness(4, 0, 0, 0), + VerticalAlignment = VerticalAlignment.Center, + Child = new TextBlock + { + Text = $"+{node.NonClusteredIndexCount} NC", + FontSize = 10, + FontWeight = FontWeight.SemiBold, + Foreground = Brushes.White + } + }; + iconRow.Children.Add(ncBadge); + } + + stack.Children.Add(iconRow); + + // Operator name + var fgBrush = FindBrushResource("ForegroundBrush"); + + // Operator name — for exchanges, show "Parallelism" + "(Gather Streams)" etc. + var opLabel = node.PhysicalOp; + if (node.PhysicalOp == "Parallelism" && !string.IsNullOrEmpty(node.LogicalOp) + && node.LogicalOp != "Parallelism") + { + opLabel = $"Parallelism\n({node.LogicalOp})"; + } + stack.Children.Add(new TextBlock + { + Text = opLabel, + FontSize = 10, + FontWeight = FontWeight.SemiBold, + Foreground = fgBrush, + TextAlignment = TextAlignment.Center, + TextWrapping = TextWrapping.Wrap, + MaxWidth = PlanLayoutEngine.NodeWidth - 16, + HorizontalAlignment = HorizontalAlignment.Center + }); + + // Cost percentage — only highlight in estimated plans; actual plans use duration/CPU colors + IBrush costColor = !node.HasActualStats && node.CostPercent >= 50 ? OrangeRedBrush + : !node.HasActualStats && node.CostPercent >= 25 ? OrangeBrush + : fgBrush; + + stack.Children.Add(new TextBlock + { + Text = $"Cost: {node.CostPercent}%", + FontSize = 10, + Foreground = costColor, + TextAlignment = TextAlignment.Center, + HorizontalAlignment = HorizontalAlignment.Center + }); + + // Actual plan stats: elapsed time, CPU time, and row counts + if (node.HasActualStats) + { + // Compute own time (subtract children in row mode) + var ownElapsedMs = GetOwnElapsedMs(node); + var ownCpuMs = GetOwnCpuMs(node); + + // Elapsed time -- color based on own time, not cumulative + var ownElapsedSec = ownElapsedMs / 1000.0; + IBrush elapsedBrush = ownElapsedSec >= 1.0 ? OrangeRedBrush + : ownElapsedSec >= 0.1 ? OrangeBrush : fgBrush; + stack.Children.Add(new TextBlock + { + Text = $"{ownElapsedSec:F3}s", + FontSize = 10, + Foreground = elapsedBrush, + TextAlignment = TextAlignment.Center, + HorizontalAlignment = HorizontalAlignment.Center + }); + + // CPU time -- color based on own time + var ownCpuSec = ownCpuMs / 1000.0; + IBrush cpuBrush = ownCpuSec >= 1.0 ? OrangeRedBrush + : ownCpuSec >= 0.1 ? OrangeBrush : fgBrush; + stack.Children.Add(new TextBlock + { + Text = $"CPU: {ownCpuSec:F3}s", + FontSize = 10, + Foreground = cpuBrush, + TextAlignment = TextAlignment.Center, + HorizontalAlignment = HorizontalAlignment.Center + }); + + // Actual rows of Estimated rows (accuracy %) -- red if off by divergence limit + var estRows = node.EstimateRows; + var accuracyRatio = estRows > 0 ? node.ActualRows / estRows : (node.ActualRows > 0 ? double.MaxValue : 1.0); + IBrush rowBrush = (accuracyRatio < 1.0 / divergenceLimit || accuracyRatio > divergenceLimit) ? OrangeRedBrush : fgBrush; + var accuracy = estRows > 0 + ? $" ({accuracyRatio * 100:F0}%)" + : ""; + stack.Children.Add(new TextBlock + { + Text = $"{node.ActualRows:N0} of {estRows:N0}{accuracy}", + FontSize = 10, + Foreground = rowBrush, + TextAlignment = TextAlignment.Center, + HorizontalAlignment = HorizontalAlignment.Center, + TextTrimming = TextTrimming.CharacterEllipsis, + MaxWidth = PlanLayoutEngine.NodeWidth - 16 + }); + } + + // Object name -- show full object name, wrap if needed + if (!string.IsNullOrEmpty(node.ObjectName)) + { + var objBlock = new TextBlock + { + Text = node.FullObjectName ?? node.ObjectName, + FontSize = 10, + Foreground = fgBrush, + TextAlignment = TextAlignment.Center, + TextWrapping = TextWrapping.Wrap, + MaxWidth = PlanLayoutEngine.NodeWidth - 16, + HorizontalAlignment = HorizontalAlignment.Center + }; + stack.Children.Add(objBlock); + } + + // Total warning count badge on root node + if (totalWarningCount > 0) + { + var badgeRow = new StackPanel + { + Orientation = Orientation.Horizontal, + HorizontalAlignment = HorizontalAlignment.Center, + Margin = new Thickness(0, 2, 0, 0) + }; + badgeRow.Children.Add(new TextBlock + { + Text = "\u26A0", + FontSize = 13, + Foreground = OrangeBrush, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 0, 4, 0) + }); + badgeRow.Children.Add(new TextBlock + { + Text = $"{totalWarningCount} warning{(totalWarningCount == 1 ? "" : "s")}", + FontSize = 12, + FontWeight = FontWeight.SemiBold, + Foreground = OrangeBrush, + VerticalAlignment = VerticalAlignment.Center + }); + stack.Children.Add(badgeRow); + } + + border.Child = stack; + return border; + } + + private void RenderEdges(PlanNode node, double divergenceLimit) + { + foreach (var child in node.Children) + { + var path = CreateElbowConnector(node, child, divergenceLimit); + PlanCanvas.Children.Add(path); + + RenderEdges(child, divergenceLimit); + } + } + + /// + /// Returns a color brush for a link based on the accuracy ratio of the child node. + /// Only applies to actual plans; estimated plans use the default edge brush. + /// + private static IBrush GetLinkColorBrush(PlanNode child, double divergenceLimit) + { + if (!child.HasActualStats) + return EdgeBrush; + + divergenceLimit = Math.Max(2.0, divergenceLimit); + var estRows = child.EstimateRows; + var accuracyRatio = estRows > 0 + ? child.ActualRows / estRows + : (child.ActualRows > 0 ? double.MaxValue : 1.0); + + // Within the neutral band — keep default color + if (accuracyRatio >= 1.0 / divergenceLimit && accuracyRatio <= divergenceLimit) + return EdgeBrush; + + // Underestimated bands (accuracyRatio > 1 means more actual rows than estimated) + if (accuracyRatio > divergenceLimit) + { + if (accuracyRatio >= divergenceLimit * 100) + return LinkFluoRedBrush; + if (accuracyRatio >= divergenceLimit * 10) + return LinkFluoOrangeBrush; + return LinkLightOrangeBrush; + } + + // Overestimated bands (accuracyRatio < 1 means fewer actual rows than estimated) + if (accuracyRatio < 1.0 / (divergenceLimit * 100)) + return LinkFluoBlueBrush; + if (accuracyRatio < 1.0 / (divergenceLimit * 10)) + return LinkLightBlueBrush; + return LinkBlueBrush; + } + + private AvaloniaPath CreateElbowConnector(PlanNode parent, PlanNode child, double divergenceLimit) + { + var parentRight = parent.X + PlanLayoutEngine.NodeWidth; + var parentCenterY = parent.Y + PlanLayoutEngine.GetNodeHeight(parent) / 2; + var childLeft = child.X; + var childCenterY = child.Y + PlanLayoutEngine.GetNodeHeight(child) / 2; + + // Arrow thickness based on row estimate (logarithmic) + var rows = child.HasActualStats ? child.ActualRows : child.EstimateRows; + var thickness = Math.Max(2, Math.Min(Math.Floor(Math.Log(Math.Max(1, rows))), 12)); + + var midX = (parentRight + childLeft) / 2; + + var geometry = new PathGeometry(); + var figure = new PathFigure + { + StartPoint = new Point(parentRight, parentCenterY), + IsClosed = false + }; + figure.Segments!.Add(new LineSegment { Point = new Point(midX, parentCenterY) }); + figure.Segments.Add(new LineSegment { Point = new Point(midX, childCenterY) }); + figure.Segments.Add(new LineSegment { Point = new Point(childLeft, childCenterY) }); + geometry.Figures!.Add(figure); + + var linkBrush = GetLinkColorBrush(child, divergenceLimit); + + var path = new AvaloniaPath + { + Data = geometry, + Stroke = linkBrush, + StrokeThickness = thickness, + StrokeJoin = PenLineJoin.Round + }; + ToolTip.SetTip(path, BuildEdgeTooltipContent(child)); + return path; + } + + private object BuildEdgeTooltipContent(PlanNode child) + { + var panel = new StackPanel { MinWidth = 240 }; + + void AddRow(string label, string value) + { + var row = new Grid(); + row.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Star)); + row.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Auto)); + var lbl = new TextBlock + { + Text = label, + Foreground = new SolidColorBrush(Color.FromRgb(0xE0, 0xE0, 0xE0)), + FontSize = 12, + Margin = new Thickness(0, 1, 12, 1) + }; + var val = new TextBlock + { + Text = value, + Foreground = new SolidColorBrush(Color.FromRgb(0xFF, 0xFF, 0xFF)), + FontSize = 12, + FontWeight = FontWeight.SemiBold, + HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Right, + Margin = new Thickness(0, 1, 0, 1) + }; + Grid.SetColumn(lbl, 0); + Grid.SetColumn(val, 1); + row.Children.Add(lbl); + row.Children.Add(val); + panel.Children.Add(row); + } + + if (child.HasActualStats) + AddRow("Actual Number of Rows for All Executions", $"{child.ActualRows:N0}"); + + AddRow("Estimated Number of Rows Per Execution", $"{child.EstimateRows:N0}"); + + var executions = 1.0 + child.EstimateRebinds + child.EstimateRewinds; + var estimatedRowsAllExec = child.EstimateRows * executions; + AddRow("Estimated Number of Rows for All Executions", $"{estimatedRowsAllExec:N0}"); + + if (child.EstimatedRowSize > 0) + { + AddRow("Estimated Row Size", FormatBytes(child.EstimatedRowSize)); + var dataSize = estimatedRowsAllExec * child.EstimatedRowSize; + AddRow("Estimated Data Size", FormatBytes(dataSize)); + } + + return new Border + { + Background = new SolidColorBrush(Color.FromRgb(0x1E, 0x1E, 0x2E)), + BorderBrush = new SolidColorBrush(Color.FromRgb(0x3A, 0x3A, 0x5A)), + BorderThickness = new Thickness(1), + Padding = new Thickness(10, 6), + CornerRadius = new CornerRadius(4), + Child = panel + }; + } + + private static string FormatBytes(double bytes) + { + if (bytes < 1024) return $"{bytes:N0} B"; + if (bytes < 1024 * 1024) return $"{bytes / 1024:N0} KB"; + if (bytes < 1024L * 1024 * 1024) return $"{bytes / (1024 * 1024):N0} MB"; + return $"{bytes / (1024L * 1024 * 1024):N1} GB"; + } + + private static string FormatBenefitPercent(double pct) => + pct >= 100 ? $"{pct:N0}" : $"{pct:N1}"; + + private static bool HasSpillInPlanTree(PlanNode node) + { + foreach (var w in node.Warnings) + if (w.WarningType.EndsWith(" Spill", StringComparison.Ordinal)) return true; + foreach (var child in node.Children) + if (HasSpillInPlanTree(child)) return true; + return false; + } + + private static void CollectWarnings(PlanNode node, List warnings) + { + warnings.AddRange(node.Warnings); + foreach (var child in node.Children) + CollectWarnings(child, warnings); + } + + private IBrush FindBrushResource(string key) + { + if (this.TryFindResource(key, out var resource) && resource is IBrush brush) + return brush; + + // Fallback brushes in case resources are not found + return key switch + { + "BackgroundLightBrush" => new SolidColorBrush(Color.FromRgb(0x23, 0x26, 0x2E)), + "BorderBrush" => new SolidColorBrush(Color.FromRgb(0x3A, 0x3D, 0x45)), + "ForegroundBrush" => new SolidColorBrush(Color.FromRgb(0xE4, 0xE6, 0xEB)), + "ForegroundMutedBrush" => new SolidColorBrush(Color.FromRgb(0xE4, 0xE6, 0xEB)), + _ => Brushes.White + }; + } +} diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.Schema.cs b/src/PlanViewer.App/Controls/PlanViewerControl.Schema.cs new file mode 100644 index 0000000..a765092 --- /dev/null +++ b/src/PlanViewer.App/Controls/PlanViewerControl.Schema.cs @@ -0,0 +1,347 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Interactivity; +using Avalonia.Layout; +using Avalonia.Media; +using AvaloniaEdit.TextMate; +using Microsoft.Data.SqlClient; +using PlanViewer.Core.Interfaces; +using PlanViewer.Core.Models; +using PlanViewer.Core.Services; + +namespace PlanViewer.App.Controls; + +public partial class PlanViewerControl : UserControl +{ + private static bool IsTempObject(string objectName) + { + // #temp tables, ##global temp, @table variables, internal worktables + return objectName.Contains('#') || objectName.Contains('@') + || objectName.Contains("worktable", StringComparison.OrdinalIgnoreCase) + || objectName.Contains("worksort", StringComparison.OrdinalIgnoreCase); + } + + private static bool IsDataAccessOperator(PlanNode node) + { + var op = node.PhysicalOp; + if (string.IsNullOrEmpty(op)) return false; + + // Modification operators and data access operators reference objects + return op.Contains("Scan", StringComparison.OrdinalIgnoreCase) + || op.Contains("Seek", StringComparison.OrdinalIgnoreCase) + || op.Contains("Lookup", StringComparison.OrdinalIgnoreCase) + || op.Contains("Insert", StringComparison.OrdinalIgnoreCase) + || op.Contains("Update", StringComparison.OrdinalIgnoreCase) + || op.Contains("Delete", StringComparison.OrdinalIgnoreCase) + || op.Contains("Spool", StringComparison.OrdinalIgnoreCase); + } + + private void AddSchemaMenuItems(ContextMenu menu, PlanNode node) + { + if (string.IsNullOrEmpty(node.ObjectName) || IsTempObject(node.ObjectName)) + return; + if (!IsDataAccessOperator(node)) + return; + + var objectName = node.ObjectName; + + menu.Items.Add(new Separator()); + + var showIndexes = new MenuItem { Header = $"Show Indexes — {objectName}" }; + showIndexes.Click += async (_, _) => await FetchAndShowSchemaAsync("Indexes", objectName, + async cs => FormatIndexes(objectName, await SchemaQueryService.FetchIndexesAsync(cs, objectName))); + menu.Items.Add(showIndexes); + + var showTableDef = new MenuItem { Header = $"Show Table Definition — {objectName}" }; + showTableDef.Click += async (_, _) => await FetchAndShowSchemaAsync("Table", objectName, + async cs => + { + var columns = await SchemaQueryService.FetchColumnsAsync(cs, objectName); + var indexes = await SchemaQueryService.FetchIndexesAsync(cs, objectName); + return FormatColumns(objectName, columns, indexes); + }); + menu.Items.Add(showTableDef); + + // Disable schema items when no connection + menu.Opening += (_, _) => + { + var enabled = ConnectionString != null; + showIndexes.IsEnabled = enabled; + showTableDef.IsEnabled = enabled; + }; + } + + private async System.Threading.Tasks.Task FetchAndShowSchemaAsync( + string kind, string objectName, Func> fetch) + { + if (ConnectionString == null) return; + + try + { + var content = await fetch(ConnectionString); + ShowSchemaResult($"{kind} — {objectName}", content); + } + catch (Exception ex) + { + ShowSchemaResult($"Error — {objectName}", $"-- Error: {ex.Message}"); + } + } + + private void ShowSchemaResult(string title, string content) + { + var editor = new AvaloniaEdit.TextEditor + { + Text = content, + IsReadOnly = true, + FontFamily = new FontFamily("Consolas, Menlo, monospace"), + FontSize = 13, + ShowLineNumbers = true, + Background = FindBrushResource("BackgroundBrush"), + Foreground = FindBrushResource("ForegroundBrush"), + HorizontalScrollBarVisibility = ScrollBarVisibility.Auto, + VerticalScrollBarVisibility = ScrollBarVisibility.Auto, + Padding = new Thickness(4) + }; + + // SQL syntax highlighting + var registryOptions = new TextMateSharp.Grammars.RegistryOptions(TextMateSharp.Grammars.ThemeName.DarkPlus); + var tm = editor.InstallTextMate(registryOptions); + tm.SetGrammar(registryOptions.GetScopeByLanguageId("sql")); + + // Context menu + var copyItem = new MenuItem { Header = "Copy" }; + copyItem.Click += async (_, _) => + { + var clipboard = TopLevel.GetTopLevel(this)?.Clipboard; + if (clipboard == null) return; + var sel = editor.TextArea.Selection; + if (!sel.IsEmpty) + await clipboard.SetTextAsync(sel.GetText()); + }; + var copyAllItem = new MenuItem { Header = "Copy All" }; + copyAllItem.Click += async (_, _) => + { + var clipboard = TopLevel.GetTopLevel(this)?.Clipboard; + if (clipboard == null) return; + await clipboard.SetTextAsync(editor.Text); + }; + var selectAllItem = new MenuItem { Header = "Select All" }; + selectAllItem.Click += (_, _) => editor.SelectAll(); + editor.TextArea.ContextMenu = new ContextMenu + { + Items = { copyItem, copyAllItem, new Separator(), selectAllItem } + }; + + // Show in a popup window + var window = new Window + { + Title = $"Performance Studio — {title}", + Width = 700, + Height = 500, + MinWidth = 400, + MinHeight = 200, + Background = FindBrushResource("BackgroundBrush"), + Foreground = FindBrushResource("ForegroundBrush"), + Content = editor + }; + + var topLevel = TopLevel.GetTopLevel(this); + if (topLevel is Window parentWindow) + { + window.Icon = parentWindow.Icon; + window.Show(parentWindow); + } + else + { + window.Show(); + } + } + + 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/PlanViewerControl.Statements.cs b/src/PlanViewer.App/Controls/PlanViewerControl.Statements.cs new file mode 100644 index 0000000..a03c712 --- /dev/null +++ b/src/PlanViewer.App/Controls/PlanViewerControl.Statements.cs @@ -0,0 +1,222 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Templates; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Layout; +using Avalonia.Media; +using PlanViewer.Core.Models; +using PlanViewer.Core.Services; + +namespace PlanViewer.App.Controls; + +public partial class PlanViewerControl : UserControl +{ + private void PopulateStatementsGrid(List statements) + { + StatementsHeader.Text = $"Statements ({statements.Count})"; + + var hasActualTimes = statements.Any(s => s.QueryTimeStats != null && + (s.QueryTimeStats.CpuTimeMs > 0 || s.QueryTimeStats.ElapsedTimeMs > 0)); + var hasUdf = statements.Any(s => s.QueryUdfElapsedTimeMs > 0); + + // Build columns + StatementsGrid.Columns.Clear(); + + StatementsGrid.Columns.Add(new DataGridTextColumn + { + Header = "#", + Binding = new Avalonia.Data.Binding("Index"), + Width = new DataGridLength(40), + IsReadOnly = true + }); + + var queryTemplate = new FuncDataTemplate((row, _) => + { + if (row == null) return new TextBlock(); + var tb = new TextBlock + { + Text = row.QueryText, + TextWrapping = TextWrapping.Wrap, + MaxHeight = 80, + FontSize = 11, + Margin = new Thickness(4, 2) + }; + ToolTip.SetTip(tb, new TextBlock + { + Text = row.FullQueryText, + TextWrapping = TextWrapping.Wrap, + MaxWidth = 600, + FontFamily = new FontFamily("Consolas"), + FontSize = 11 + }); + return tb; + }, supportsRecycling: false); + + StatementsGrid.Columns.Add(new DataGridTemplateColumn + { + Header = "Query", + CellTemplate = queryTemplate, + Width = new DataGridLength(250), + IsReadOnly = true + }); + + if (hasActualTimes) + { + StatementsGrid.Columns.Add(new DataGridTextColumn + { + Header = "CPU", + Binding = new Avalonia.Data.Binding("CpuDisplay"), + Width = new DataGridLength(70), + IsReadOnly = true, + CustomSortComparer = new LongComparer(r => r.CpuMs) + }); + StatementsGrid.Columns.Add(new DataGridTextColumn + { + Header = "Elapsed", + Binding = new Avalonia.Data.Binding("ElapsedDisplay"), + Width = new DataGridLength(70), + IsReadOnly = true, + CustomSortComparer = new LongComparer(r => r.ElapsedMs) + }); + } + + if (hasUdf) + { + StatementsGrid.Columns.Add(new DataGridTextColumn + { + Header = "UDF", + Binding = new Avalonia.Data.Binding("UdfDisplay"), + Width = new DataGridLength(70), + IsReadOnly = true, + CustomSortComparer = new LongComparer(r => r.UdfMs) + }); + } + + if (!hasActualTimes) + { + StatementsGrid.Columns.Add(new DataGridTextColumn + { + Header = "Est. Cost", + Binding = new Avalonia.Data.Binding("CostDisplay"), + Width = new DataGridLength(80), + IsReadOnly = true, + CustomSortComparer = new DoubleComparer(r => r.EstCost) + }); + } + + StatementsGrid.Columns.Add(new DataGridTextColumn + { + Header = "Critical", + Binding = new Avalonia.Data.Binding("Critical"), + Width = new DataGridLength(60), + IsReadOnly = true + }); + + StatementsGrid.Columns.Add(new DataGridTextColumn + { + Header = "Warnings", + Binding = new Avalonia.Data.Binding("Warnings"), + Width = new DataGridLength(70), + IsReadOnly = true + }); + + // Build rows + var rows = new List(); + for (int i = 0; i < statements.Count; i++) + { + var stmt = statements[i]; + var allWarnings = stmt.PlanWarnings.ToList(); + if (stmt.RootNode != null) + CollectNodeWarnings(stmt.RootNode, allWarnings); + + var fullText = stmt.StatementText; + if (string.IsNullOrWhiteSpace(fullText)) + fullText = $"Statement {i + 1}"; + var displayText = fullText.Length > 120 ? fullText[..120] + "..." : fullText; + + rows.Add(new StatementRow + { + Index = i + 1, + QueryText = displayText, + FullQueryText = fullText, + CpuMs = stmt.QueryTimeStats?.CpuTimeMs ?? 0, + ElapsedMs = stmt.QueryTimeStats?.ElapsedTimeMs ?? 0, + UdfMs = stmt.QueryUdfElapsedTimeMs, + EstCost = stmt.StatementSubTreeCost, + Critical = allWarnings.Count(w => w.Severity == PlanWarningSeverity.Critical), + Warnings = allWarnings.Count(w => w.Severity == PlanWarningSeverity.Warning), + Statement = stmt + }); + } + + StatementsGrid.ItemsSource = rows; + } + + private void StatementsGrid_SelectionChanged(object? sender, SelectionChangedEventArgs e) + { + if (StatementsGrid.SelectedItem is StatementRow row) + RenderStatement(row.Statement); + } + + private async void CopyStatementText_Click(object? sender, RoutedEventArgs e) + { + if (StatementsGrid.SelectedItem is not StatementRow row) return; + var text = row.Statement.StatementText; + if (string.IsNullOrEmpty(text)) return; + + var topLevel = TopLevel.GetTopLevel(this); + if (topLevel?.Clipboard != null) + await topLevel.Clipboard.SetTextAsync(text); + } + + private void OpenInEditor_Click(object? sender, RoutedEventArgs e) + { + if (StatementsGrid.SelectedItem is not StatementRow row) return; + var text = row.Statement.StatementText; + if (string.IsNullOrEmpty(text)) return; + + OpenInEditorRequested?.Invoke(this, text); + } + + private static void CollectNodeWarnings(PlanNode node, List warnings) + { + warnings.AddRange(node.Warnings); + foreach (var child in node.Children) + CollectNodeWarnings(child, warnings); + } + + private void ToggleStatements_Click(object? sender, RoutedEventArgs e) + { + if (StatementsPanel.IsVisible) + CloseStatementsPanel(); + else + ShowStatementsPanel(); + } + + private void CloseStatements_Click(object? sender, RoutedEventArgs e) + { + CloseStatementsPanel(); + } + + private void ShowStatementsPanel() + { + _statementsColumn.Width = new GridLength(450); + _statementsSplitterColumn.Width = new GridLength(5); + StatementsSplitter.IsVisible = true; + StatementsPanel.IsVisible = true; + StatementsButton.IsVisible = true; + StatementsButtonSeparator.IsVisible = true; + } + + private void CloseStatementsPanel() + { + StatementsPanel.IsVisible = false; + StatementsSplitter.IsVisible = false; + _statementsColumn.Width = new GridLength(0); + _statementsSplitterColumn.Width = new GridLength(0); + } +} diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.Tooltips.cs b/src/PlanViewer.App/Controls/PlanViewerControl.Tooltips.cs new file mode 100644 index 0000000..841283f --- /dev/null +++ b/src/PlanViewer.App/Controls/PlanViewerControl.Tooltips.cs @@ -0,0 +1,278 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Layout; +using Avalonia.Media; +using PlanViewer.Core.Models; + +namespace PlanViewer.App.Controls; + +public partial class PlanViewerControl : UserControl +{ + private object BuildNodeTooltipContent(PlanNode node, List? allWarnings = null) + { + var tipBorder = new Border + { + Background = TooltipBgBrush, + BorderBrush = TooltipBorderBrush, + BorderThickness = new Thickness(1), + Padding = new Thickness(12), + MaxWidth = 500 + }; + + var stack = new StackPanel(); + + // Header + var headerText = node.PhysicalOp; + if (node.LogicalOp != node.PhysicalOp && !string.IsNullOrEmpty(node.LogicalOp) + && !node.PhysicalOp.Contains(node.LogicalOp, StringComparison.OrdinalIgnoreCase)) + headerText += $" ({node.LogicalOp})"; + stack.Children.Add(new TextBlock + { + Text = headerText, + FontWeight = FontWeight.Bold, + FontSize = 13, + Foreground = TooltipFgBrush, + Margin = new Thickness(0, 0, 0, 8) + }); + + // Cost + AddTooltipSection(stack, "Costs"); + AddTooltipRow(stack, "Cost", $"{node.CostPercent}% of statement ({node.EstimatedOperatorCost:F6})"); + AddTooltipRow(stack, "Subtree Cost", $"{node.EstimatedTotalSubtreeCost:F6}"); + + // Rows + AddTooltipSection(stack, "Rows"); + AddTooltipRow(stack, "Estimated Rows", $"{node.EstimateRows:N1}"); + if (node.HasActualStats) + { + AddTooltipRow(stack, "Actual Rows", $"{node.ActualRows:N0}"); + if (node.ActualRowsRead > 0) + AddTooltipRow(stack, "Actual Rows Read", $"{node.ActualRowsRead:N0}"); + AddTooltipRow(stack, "Actual Executions", $"{node.ActualExecutions:N0}"); + } + + // Rebinds/Rewinds (spools and other operators with rebind/rewind data) + if (node.EstimateRebinds > 0 || node.EstimateRewinds > 0 + || node.ActualRebinds > 0 || node.ActualRewinds > 0) + { + AddTooltipSection(stack, "Rebinds / Rewinds"); + // Always show both estimated values when section is visible + AddTooltipRow(stack, "Est. Rebinds", $"{node.EstimateRebinds:N1}"); + AddTooltipRow(stack, "Est. Rewinds", $"{node.EstimateRewinds:N1}"); + if (node.ActualRebinds > 0) AddTooltipRow(stack, "Actual Rebinds", $"{node.ActualRebinds:N0}"); + if (node.ActualRewinds > 0) AddTooltipRow(stack, "Actual Rewinds", $"{node.ActualRewinds:N0}"); + } + + // I/O and CPU estimates + if (node.EstimateIO > 0 || node.EstimateCPU > 0 || node.EstimatedRowSize > 0) + { + AddTooltipSection(stack, "Estimates"); + if (node.EstimateIO > 0) AddTooltipRow(stack, "I/O Cost", $"{node.EstimateIO:F6}"); + if (node.EstimateCPU > 0) AddTooltipRow(stack, "CPU Cost", $"{node.EstimateCPU:F6}"); + if (node.EstimatedRowSize > 0) AddTooltipRow(stack, "Avg Row Size", $"{node.EstimatedRowSize} B"); + } + + // Actual I/O + if (node.HasActualStats && (node.ActualLogicalReads > 0 || node.ActualPhysicalReads > 0)) + { + AddTooltipSection(stack, "Actual I/O"); + AddTooltipRow(stack, "Logical Reads", $"{node.ActualLogicalReads:N0}"); + if (node.ActualPhysicalReads > 0) + AddTooltipRow(stack, "Physical Reads", $"{node.ActualPhysicalReads:N0}"); + if (node.ActualScans > 0) + AddTooltipRow(stack, "Scans", $"{node.ActualScans:N0}"); + if (node.ActualReadAheads > 0) + AddTooltipRow(stack, "Read-Aheads", $"{node.ActualReadAheads:N0}"); + } + + // Actual timing + if (node.HasActualStats && (node.ActualElapsedMs > 0 || node.ActualCPUMs > 0)) + { + AddTooltipSection(stack, "Timing"); + if (node.ActualElapsedMs > 0) + AddTooltipRow(stack, "Elapsed Time", $"{node.ActualElapsedMs:N0} ms"); + if (node.ActualCPUMs > 0) + AddTooltipRow(stack, "CPU Time", $"{node.ActualCPUMs:N0} ms"); + } + + // Parallelism + if (node.Parallel || !string.IsNullOrEmpty(node.ExecutionMode) || !string.IsNullOrEmpty(node.PartitioningType)) + { + AddTooltipSection(stack, "Parallelism"); + if (node.Parallel) AddTooltipRow(stack, "Parallel", "Yes"); + if (!string.IsNullOrEmpty(node.ExecutionMode)) + AddTooltipRow(stack, "Execution Mode", node.ExecutionMode); + if (!string.IsNullOrEmpty(node.ActualExecutionMode) && node.ActualExecutionMode != node.ExecutionMode) + AddTooltipRow(stack, "Actual Exec Mode", node.ActualExecutionMode); + if (!string.IsNullOrEmpty(node.PartitioningType)) + AddTooltipRow(stack, "Partitioning", node.PartitioningType); + } + + // Object + if (!string.IsNullOrEmpty(node.FullObjectName)) + { + AddTooltipSection(stack, "Object"); + AddTooltipRow(stack, "Name", node.FullObjectName, isCode: true); + if (node.Ordered) AddTooltipRow(stack, "Ordered", "True"); + if (!string.IsNullOrEmpty(node.ScanDirection)) + AddTooltipRow(stack, "Scan Direction", node.ScanDirection); + } + else if (!string.IsNullOrEmpty(node.ObjectName)) + { + AddTooltipSection(stack, "Object"); + AddTooltipRow(stack, "Name", node.ObjectName, isCode: true); + if (node.Ordered) AddTooltipRow(stack, "Ordered", "True"); + if (!string.IsNullOrEmpty(node.ScanDirection)) + AddTooltipRow(stack, "Scan Direction", node.ScanDirection); + } + + // NC index maintenance count + if (node.NonClusteredIndexCount > 0) + AddTooltipRow(stack, "NC Indexes Maintained", string.Join(", ", node.NonClusteredIndexNames)); + + // Operator details (key items only in tooltip) + var hasTooltipDetails = !string.IsNullOrEmpty(node.OrderBy) + || !string.IsNullOrEmpty(node.TopExpression) + || !string.IsNullOrEmpty(node.GroupBy) + || !string.IsNullOrEmpty(node.OuterReferences); + if (hasTooltipDetails) + { + AddTooltipSection(stack, "Details"); + if (!string.IsNullOrEmpty(node.OrderBy)) + AddTooltipRow(stack, "Order By", node.OrderBy, isCode: true); + if (!string.IsNullOrEmpty(node.TopExpression)) + AddTooltipRow(stack, "Top", node.IsPercent ? $"{node.TopExpression} PERCENT" : node.TopExpression); + if (!string.IsNullOrEmpty(node.GroupBy)) + AddTooltipRow(stack, "Group By", node.GroupBy, isCode: true); + if (!string.IsNullOrEmpty(node.OuterReferences)) + AddTooltipRow(stack, "Outer References", node.OuterReferences, isCode: true); + } + + // Predicates + if (!string.IsNullOrEmpty(node.SeekPredicates) || !string.IsNullOrEmpty(node.Predicate)) + { + AddTooltipSection(stack, "Predicates"); + if (!string.IsNullOrEmpty(node.SeekPredicates)) + AddTooltipRow(stack, "Seek", node.SeekPredicates, isCode: true); + if (!string.IsNullOrEmpty(node.Predicate)) + AddTooltipRow(stack, "Residual", node.Predicate, isCode: true); + } + + // Output columns + if (!string.IsNullOrEmpty(node.OutputColumns)) + { + AddTooltipSection(stack, "Output"); + AddTooltipRow(stack, "Columns", node.OutputColumns, isCode: true); + } + + // Warnings — use allWarnings (all nodes) for root, node.Warnings for others + var warnings = allWarnings ?? (node.HasWarnings ? node.Warnings : null); + if (warnings != null && warnings.Count > 0) + { + stack.Children.Add(new Separator { Margin = new Thickness(0, 6, 0, 6) }); + + if (allWarnings != null) + { + // Root node: show distinct warning type names only, sorted by max benefit + var distinct = warnings + .GroupBy(w => w.WarningType) + .Select(g => (Type: g.Key, MaxSeverity: g.Max(w => w.Severity), Count: g.Count(), + MaxBenefit: g.Max(w => w.MaxBenefitPercent ?? -1))) + .OrderByDescending(g => g.MaxBenefit) + .ThenByDescending(g => g.MaxSeverity) + .ThenBy(g => g.Type); + + foreach (var (type, severity, count, maxBenefit) in distinct) + { + var warnColor = severity == PlanWarningSeverity.Critical ? "#E57373" + : severity == PlanWarningSeverity.Warning ? "#FFB347" : "#6BB5FF"; + var benefitSuffix = maxBenefit >= 0 ? $" \u2014 up to {maxBenefit:N0}%" : ""; + var label = count > 1 ? $"\u26A0 {type} ({count}){benefitSuffix}" : $"\u26A0 {type}{benefitSuffix}"; + stack.Children.Add(new TextBlock + { + Text = label, + Foreground = new SolidColorBrush(Color.Parse(warnColor)), + FontSize = 11, + Margin = new Thickness(0, 2, 0, 0) + }); + } + } + else + { + // Individual node: show full warning messages + foreach (var w in warnings) + { + var warnColor = w.Severity == PlanWarningSeverity.Critical ? "#E57373" + : w.Severity == PlanWarningSeverity.Warning ? "#FFB347" : "#6BB5FF"; + stack.Children.Add(new TextBlock + { + Text = $"\u26A0 {w.WarningType}: {w.Message}", + Foreground = new SolidColorBrush(Color.Parse(warnColor)), + FontSize = 11, + TextWrapping = TextWrapping.Wrap, + Margin = new Thickness(0, 2, 0, 0) + }); + } + } + } + + // Footer hint + stack.Children.Add(new TextBlock + { + Text = "Click to view full properties", + FontSize = 10, + FontStyle = FontStyle.Italic, + Foreground = TooltipFgBrush, + Margin = new Thickness(0, 8, 0, 0) + }); + + tipBorder.Child = stack; + return tipBorder; + } + + private static void AddTooltipSection(StackPanel parent, string title) + { + parent.Children.Add(new TextBlock + { + Text = title, + FontSize = 10, + FontWeight = FontWeight.SemiBold, + Foreground = SectionHeaderBrush, + Margin = new Thickness(0, 6, 0, 2) + }); + } + + private static void AddTooltipRow(StackPanel parent, string label, string value, bool isCode = false) + { + var row = new Grid + { + ColumnDefinitions = new ColumnDefinitions("Auto,*"), + Margin = new Thickness(0, 1, 0, 1) + }; + var labelBlock = new TextBlock + { + Text = $"{label}: ", + Foreground = TooltipFgBrush, + FontSize = 11, + MinWidth = 120, + VerticalAlignment = VerticalAlignment.Top + }; + Grid.SetColumn(labelBlock, 0); + row.Children.Add(labelBlock); + + var valueBlock = new TextBlock + { + Text = value, + FontSize = 11, + Foreground = TooltipFgBrush, + TextWrapping = TextWrapping.Wrap + }; + if (isCode) valueBlock.FontFamily = new FontFamily("Consolas"); + Grid.SetColumn(valueBlock, 1); + row.Children.Add(valueBlock); + parent.Children.Add(row); + } +} diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs b/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs index a2d77e3..f6925f4 100644 --- a/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs +++ b/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs @@ -378,3254 +378,9 @@ public void Clear() CloseMinimapPanel(); } - private static void CountNodeWarnings(PlanNode node, ref int total, ref int critical) - { - total += node.Warnings.Count; - critical += node.Warnings.Count(w => w.Severity == PlanWarningSeverity.Critical); - foreach (var child in node.Children) - CountNodeWarnings(child, ref total, ref critical); - } - - private void RenderStatement(PlanStatement statement) - { - _currentStatement = statement; - PlanCanvas.Children.Clear(); - _nodeBorderMap.Clear(); - _selectedNodeBorder = null; - _selectedNode = null; - - if (statement.RootNode == null) return; - - // Layout - PlanLayoutEngine.Layout(statement); - var (width, height) = PlanLayoutEngine.GetExtents(statement.RootNode); - PlanCanvas.Width = width; - PlanCanvas.Height = height; - - // Render edges first (behind nodes) - var divergenceLimit = Math.Max(2.0, AppSettingsService.Load().AccuracyRatioDivergenceLimit); - RenderEdges(statement.RootNode, divergenceLimit); - - // Render nodes — pass total warning count to root node for badge - var allWarnings = new List(); - CollectWarnings(statement.RootNode, allWarnings); - RenderNodes(statement.RootNode, divergenceLimit, allWarnings.Count); - - // Update banners - ShowMissingIndexes(statement.MissingIndexes); - ShowParameters(statement); - ShowWaitStats(statement.WaitStats, statement.WaitBenefits, statement.QueryTimeStats != null); - ShowRuntimeSummary(statement); - UpdateInsightsHeader(); - - // Scroll to top-left so the plan root is immediately visible - PlanScrollViewer.Offset = new Avalonia.Vector(0, 0); - - // Canvas-level context menu (zoom, advice, repro, save) - // Set on ScrollViewer, not Canvas — Canvas has no background so it's not hit-testable - PlanScrollViewer.ContextMenu = BuildCanvasContextMenu(); - - CostText.Text = ""; - - // Update minimap if visible - if (MinimapPanel.IsVisible) - Avalonia.Threading.Dispatcher.UIThread.Post(RenderMinimap, Avalonia.Threading.DispatcherPriority.Loaded); - } - - #region Node Rendering - - private void RenderNodes(PlanNode node, double divergenceLimit, int totalWarningCount = -1) - { - var visual = CreateNodeVisual(node, divergenceLimit, totalWarningCount); - Canvas.SetLeft(visual, node.X); - Canvas.SetTop(visual, node.Y); - PlanCanvas.Children.Add(visual); - - foreach (var child in node.Children) - RenderNodes(child, divergenceLimit); - } - - private Border CreateNodeVisual(PlanNode node, double divergenceLimit, int totalWarningCount = -1) - { - var isExpensive = node.IsExpensive; - - var bgBrush = isExpensive - ? new SolidColorBrush(Color.FromArgb(0x30, 0xE5, 0x73, 0x73)) - : FindBrushResource("BackgroundLightBrush"); - - var borderBrush = isExpensive - ? OrangeRedBrush - : FindBrushResource("BorderBrush"); - - var border = new Border - { - Width = PlanLayoutEngine.NodeWidth, - MinHeight = PlanLayoutEngine.NodeHeightMin, - Background = bgBrush, - BorderBrush = borderBrush, - BorderThickness = new Thickness(isExpensive ? 2 : 1), - CornerRadius = new CornerRadius(4), - Padding = new Thickness(6, 4, 6, 4), - Cursor = new Cursor(StandardCursorType.Hand) - }; - - // Map border to node (replaces WPF Tag) - _nodeBorderMap[border] = node; - - // Tooltip — root node gets all collected warnings so the tooltip shows them - if (totalWarningCount > 0) - { - var allWarnings = new List(); - if (_currentStatement != null) - allWarnings.AddRange(_currentStatement.PlanWarnings); - CollectWarnings(node, allWarnings); - ToolTip.SetTip(border, BuildNodeTooltipContent(node, allWarnings)); - } - else - { - ToolTip.SetTip(border, BuildNodeTooltipContent(node)); - } - - // Click to select + show properties - border.PointerPressed += Node_Click; - - // Right-click context menu - border.ContextMenu = BuildNodeContextMenu(node); - - var stack = new StackPanel { HorizontalAlignment = HorizontalAlignment.Center }; - - // Icon row: icon + optional warning/parallel indicators - var iconRow = new StackPanel - { - Orientation = Orientation.Horizontal, - HorizontalAlignment = HorizontalAlignment.Center - }; - - var iconBitmap = IconHelper.LoadIcon(node.IconName); - if (iconBitmap != null) - { - iconRow.Children.Add(new Image - { - Source = iconBitmap, - Width = 32, - Height = 32, - Margin = new Thickness(0, 0, 0, 2) - }); - } - - // Warning indicator badge (orange triangle with !) - if (node.HasWarnings) - { - var warnBadge = new Grid - { - Width = 20, Height = 20, - Margin = new Thickness(4, 0, 0, 0), - VerticalAlignment = VerticalAlignment.Center - }; - warnBadge.Children.Add(new AvaloniaPath - { - Data = StreamGeometry.Parse("M 10,0 L 20,18 L 0,18 Z"), - Fill = OrangeBrush - }); - warnBadge.Children.Add(new TextBlock - { - Text = "!", - FontSize = 12, - FontWeight = FontWeight.ExtraBold, - Foreground = Brushes.White, - HorizontalAlignment = HorizontalAlignment.Center, - Margin = new Thickness(0, 3, 0, 0) - }); - iconRow.Children.Add(warnBadge); - } - - // Parallel indicator badge (amber circle with arrows) - if (node.Parallel) - { - var parBadge = new Grid - { - Width = 20, Height = 20, - Margin = new Thickness(4, 0, 0, 0), - VerticalAlignment = VerticalAlignment.Center - }; - parBadge.Children.Add(new Ellipse - { - Width = 20, Height = 20, - Fill = new SolidColorBrush(Color.FromRgb(0xFF, 0xC1, 0x07)) - }); - parBadge.Children.Add(new TextBlock - { - Text = "\u21C6", - FontSize = 12, - FontWeight = FontWeight.Bold, - Foreground = new SolidColorBrush(Color.FromRgb(0x33, 0x33, 0x33)), - HorizontalAlignment = HorizontalAlignment.Center, - VerticalAlignment = VerticalAlignment.Center - }); - iconRow.Children.Add(parBadge); - } - - // Nonclustered index count badge (modification operators maintaining multiple NC indexes) - if (node.NonClusteredIndexCount > 0) - { - var ncBadge = new Border - { - Background = new SolidColorBrush(Color.FromRgb(0x6C, 0x75, 0x7D)), - CornerRadius = new CornerRadius(4), - Padding = new Thickness(4, 1), - Margin = new Thickness(4, 0, 0, 0), - VerticalAlignment = VerticalAlignment.Center, - Child = new TextBlock - { - Text = $"+{node.NonClusteredIndexCount} NC", - FontSize = 10, - FontWeight = FontWeight.SemiBold, - Foreground = Brushes.White - } - }; - iconRow.Children.Add(ncBadge); - } - - stack.Children.Add(iconRow); - - // Operator name - var fgBrush = FindBrushResource("ForegroundBrush"); - - // Operator name — for exchanges, show "Parallelism" + "(Gather Streams)" etc. - var opLabel = node.PhysicalOp; - if (node.PhysicalOp == "Parallelism" && !string.IsNullOrEmpty(node.LogicalOp) - && node.LogicalOp != "Parallelism") - { - opLabel = $"Parallelism\n({node.LogicalOp})"; - } - stack.Children.Add(new TextBlock - { - Text = opLabel, - FontSize = 10, - FontWeight = FontWeight.SemiBold, - Foreground = fgBrush, - TextAlignment = TextAlignment.Center, - TextWrapping = TextWrapping.Wrap, - MaxWidth = PlanLayoutEngine.NodeWidth - 16, - HorizontalAlignment = HorizontalAlignment.Center - }); - - // Cost percentage — only highlight in estimated plans; actual plans use duration/CPU colors - IBrush costColor = !node.HasActualStats && node.CostPercent >= 50 ? OrangeRedBrush - : !node.HasActualStats && node.CostPercent >= 25 ? OrangeBrush - : fgBrush; - - stack.Children.Add(new TextBlock - { - Text = $"Cost: {node.CostPercent}%", - FontSize = 10, - Foreground = costColor, - TextAlignment = TextAlignment.Center, - HorizontalAlignment = HorizontalAlignment.Center - }); - - // Actual plan stats: elapsed time, CPU time, and row counts - if (node.HasActualStats) - { - // Compute own time (subtract children in row mode) - var ownElapsedMs = GetOwnElapsedMs(node); - var ownCpuMs = GetOwnCpuMs(node); - - // Elapsed time -- color based on own time, not cumulative - var ownElapsedSec = ownElapsedMs / 1000.0; - IBrush elapsedBrush = ownElapsedSec >= 1.0 ? OrangeRedBrush - : ownElapsedSec >= 0.1 ? OrangeBrush : fgBrush; - stack.Children.Add(new TextBlock - { - Text = $"{ownElapsedSec:F3}s", - FontSize = 10, - Foreground = elapsedBrush, - TextAlignment = TextAlignment.Center, - HorizontalAlignment = HorizontalAlignment.Center - }); - - // CPU time -- color based on own time - var ownCpuSec = ownCpuMs / 1000.0; - IBrush cpuBrush = ownCpuSec >= 1.0 ? OrangeRedBrush - : ownCpuSec >= 0.1 ? OrangeBrush : fgBrush; - stack.Children.Add(new TextBlock - { - Text = $"CPU: {ownCpuSec:F3}s", - FontSize = 10, - Foreground = cpuBrush, - TextAlignment = TextAlignment.Center, - HorizontalAlignment = HorizontalAlignment.Center - }); - - // Actual rows of Estimated rows (accuracy %) -- red if off by divergence limit - var estRows = node.EstimateRows; - var accuracyRatio = estRows > 0 ? node.ActualRows / estRows : (node.ActualRows > 0 ? double.MaxValue : 1.0); - IBrush rowBrush = (accuracyRatio < 1.0 / divergenceLimit || accuracyRatio > divergenceLimit) ? OrangeRedBrush : fgBrush; - var accuracy = estRows > 0 - ? $" ({accuracyRatio * 100:F0}%)" - : ""; - stack.Children.Add(new TextBlock - { - Text = $"{node.ActualRows:N0} of {estRows:N0}{accuracy}", - FontSize = 10, - Foreground = rowBrush, - TextAlignment = TextAlignment.Center, - HorizontalAlignment = HorizontalAlignment.Center, - TextTrimming = TextTrimming.CharacterEllipsis, - MaxWidth = PlanLayoutEngine.NodeWidth - 16 - }); - } - - // Object name -- show full object name, wrap if needed - if (!string.IsNullOrEmpty(node.ObjectName)) - { - var objBlock = new TextBlock - { - Text = node.FullObjectName ?? node.ObjectName, - FontSize = 10, - Foreground = fgBrush, - TextAlignment = TextAlignment.Center, - TextWrapping = TextWrapping.Wrap, - MaxWidth = PlanLayoutEngine.NodeWidth - 16, - HorizontalAlignment = HorizontalAlignment.Center - }; - stack.Children.Add(objBlock); - } - - // Total warning count badge on root node - if (totalWarningCount > 0) - { - var badgeRow = new StackPanel - { - Orientation = Orientation.Horizontal, - HorizontalAlignment = HorizontalAlignment.Center, - Margin = new Thickness(0, 2, 0, 0) - }; - badgeRow.Children.Add(new TextBlock - { - Text = "\u26A0", - FontSize = 13, - Foreground = OrangeBrush, - VerticalAlignment = VerticalAlignment.Center, - Margin = new Thickness(0, 0, 4, 0) - }); - badgeRow.Children.Add(new TextBlock - { - Text = $"{totalWarningCount} warning{(totalWarningCount == 1 ? "" : "s")}", - FontSize = 12, - FontWeight = FontWeight.SemiBold, - Foreground = OrangeBrush, - VerticalAlignment = VerticalAlignment.Center - }); - stack.Children.Add(badgeRow); - } - - border.Child = stack; - return border; - } - - #endregion - - #region Edge Rendering - - private void RenderEdges(PlanNode node, double divergenceLimit) - { - foreach (var child in node.Children) - { - var path = CreateElbowConnector(node, child, divergenceLimit); - PlanCanvas.Children.Add(path); - - RenderEdges(child, divergenceLimit); - } - } - - /// - /// Returns a color brush for a link based on the accuracy ratio of the child node. - /// Only applies to actual plans; estimated plans use the default edge brush. - /// - private static IBrush GetLinkColorBrush(PlanNode child, double divergenceLimit) - { - if (!child.HasActualStats) - return EdgeBrush; - - divergenceLimit = Math.Max(2.0, divergenceLimit); - var estRows = child.EstimateRows; - var accuracyRatio = estRows > 0 - ? child.ActualRows / estRows - : (child.ActualRows > 0 ? double.MaxValue : 1.0); - - // Within the neutral band — keep default color - if (accuracyRatio >= 1.0 / divergenceLimit && accuracyRatio <= divergenceLimit) - return EdgeBrush; - - // Underestimated bands (accuracyRatio > 1 means more actual rows than estimated) - if (accuracyRatio > divergenceLimit) - { - if (accuracyRatio >= divergenceLimit * 100) - return LinkFluoRedBrush; - if (accuracyRatio >= divergenceLimit * 10) - return LinkFluoOrangeBrush; - return LinkLightOrangeBrush; - } - - // Overestimated bands (accuracyRatio < 1 means fewer actual rows than estimated) - if (accuracyRatio < 1.0 / (divergenceLimit * 100)) - return LinkFluoBlueBrush; - if (accuracyRatio < 1.0 / (divergenceLimit * 10)) - return LinkLightBlueBrush; - return LinkBlueBrush; - } - - private AvaloniaPath CreateElbowConnector(PlanNode parent, PlanNode child, double divergenceLimit) - { - var parentRight = parent.X + PlanLayoutEngine.NodeWidth; - var parentCenterY = parent.Y + PlanLayoutEngine.GetNodeHeight(parent) / 2; - var childLeft = child.X; - var childCenterY = child.Y + PlanLayoutEngine.GetNodeHeight(child) / 2; - - // Arrow thickness based on row estimate (logarithmic) - var rows = child.HasActualStats ? child.ActualRows : child.EstimateRows; - var thickness = Math.Max(2, Math.Min(Math.Floor(Math.Log(Math.Max(1, rows))), 12)); - - var midX = (parentRight + childLeft) / 2; - - var geometry = new PathGeometry(); - var figure = new PathFigure - { - StartPoint = new Point(parentRight, parentCenterY), - IsClosed = false - }; - figure.Segments!.Add(new LineSegment { Point = new Point(midX, parentCenterY) }); - figure.Segments.Add(new LineSegment { Point = new Point(midX, childCenterY) }); - figure.Segments.Add(new LineSegment { Point = new Point(childLeft, childCenterY) }); - geometry.Figures!.Add(figure); - - var linkBrush = GetLinkColorBrush(child, divergenceLimit); - - var path = new AvaloniaPath - { - Data = geometry, - Stroke = linkBrush, - StrokeThickness = thickness, - StrokeJoin = PenLineJoin.Round - }; - ToolTip.SetTip(path, BuildEdgeTooltipContent(child)); - return path; - } - - private object BuildEdgeTooltipContent(PlanNode child) - { - var panel = new StackPanel { MinWidth = 240 }; - - void AddRow(string label, string value) - { - var row = new Grid(); - row.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Star)); - row.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Auto)); - var lbl = new TextBlock - { - Text = label, - Foreground = new SolidColorBrush(Color.FromRgb(0xE0, 0xE0, 0xE0)), - FontSize = 12, - Margin = new Thickness(0, 1, 12, 1) - }; - var val = new TextBlock - { - Text = value, - Foreground = new SolidColorBrush(Color.FromRgb(0xFF, 0xFF, 0xFF)), - FontSize = 12, - FontWeight = FontWeight.SemiBold, - HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Right, - Margin = new Thickness(0, 1, 0, 1) - }; - Grid.SetColumn(lbl, 0); - Grid.SetColumn(val, 1); - row.Children.Add(lbl); - row.Children.Add(val); - panel.Children.Add(row); - } - - if (child.HasActualStats) - AddRow("Actual Number of Rows for All Executions", $"{child.ActualRows:N0}"); - - AddRow("Estimated Number of Rows Per Execution", $"{child.EstimateRows:N0}"); - - var executions = 1.0 + child.EstimateRebinds + child.EstimateRewinds; - var estimatedRowsAllExec = child.EstimateRows * executions; - AddRow("Estimated Number of Rows for All Executions", $"{estimatedRowsAllExec:N0}"); - - if (child.EstimatedRowSize > 0) - { - AddRow("Estimated Row Size", FormatBytes(child.EstimatedRowSize)); - var dataSize = estimatedRowsAllExec * child.EstimatedRowSize; - AddRow("Estimated Data Size", FormatBytes(dataSize)); - } - - return new Border - { - Background = new SolidColorBrush(Color.FromRgb(0x1E, 0x1E, 0x2E)), - BorderBrush = new SolidColorBrush(Color.FromRgb(0x3A, 0x3A, 0x5A)), - BorderThickness = new Thickness(1), - Padding = new Thickness(10, 6), - CornerRadius = new CornerRadius(4), - Child = panel - }; - } - - private static string FormatBytes(double bytes) - { - if (bytes < 1024) return $"{bytes:N0} B"; - if (bytes < 1024 * 1024) return $"{bytes / 1024:N0} KB"; - if (bytes < 1024L * 1024 * 1024) return $"{bytes / (1024 * 1024):N0} MB"; - return $"{bytes / (1024L * 1024 * 1024):N1} GB"; - } - - private static string FormatBenefitPercent(double pct) => - pct >= 100 ? $"{pct:N0}" : $"{pct:N1}"; - - private static bool HasSpillInPlanTree(PlanNode node) - { - foreach (var w in node.Warnings) - if (w.WarningType.EndsWith(" Spill", StringComparison.Ordinal)) return true; - foreach (var child in node.Children) - if (HasSpillInPlanTree(child)) return true; - return false; - } - - #endregion - - #region Node Selection & Properties Panel - - private void Node_Click(object? sender, PointerPressedEventArgs e) - { - if (sender is Border border - && e.GetCurrentPoint(border).Properties.IsLeftButtonPressed - && _nodeBorderMap.TryGetValue(border, out var node)) - { - SelectNode(border, node); - e.Handled = true; - } - } - - private void SelectNode(Border border, PlanNode node) - { - // Deselect previous - if (_selectedNodeBorder != null) - { - _selectedNodeBorder.BorderBrush = _selectedNodeOriginalBorder; - _selectedNodeBorder.BorderThickness = _selectedNodeOriginalThickness; - } - - // Select new - _selectedNodeOriginalBorder = border.BorderBrush; - _selectedNodeOriginalThickness = border.BorderThickness; - _selectedNodeBorder = border; - border.BorderBrush = SelectionBrush; - border.BorderThickness = new Thickness(2); - - _selectedNode = node; - ShowPropertiesPanel(node); - UpdateMinimapSelection(node); - } - - private ContextMenu BuildNodeContextMenu(PlanNode node) - { - var menu = new ContextMenu(); - - var propsItem = new MenuItem { Header = "Properties" }; - propsItem.Click += (_, _) => - { - foreach (var child in PlanCanvas.Children) - { - if (child is Border b && _nodeBorderMap.TryGetValue(b, out var n) && n == node) - { - SelectNode(b, node); - break; - } - } - }; - menu.Items.Add(propsItem); - - menu.Items.Add(new Separator()); - - var copyOpItem = new MenuItem { Header = "Copy Operator Name" }; - copyOpItem.Click += async (_, _) => await SetClipboardTextAsync(node.PhysicalOp); - menu.Items.Add(copyOpItem); - - if (!string.IsNullOrEmpty(node.FullObjectName)) - { - var copyObjItem = new MenuItem { Header = "Copy Object Name" }; - copyObjItem.Click += async (_, _) => await SetClipboardTextAsync(node.FullObjectName!); - menu.Items.Add(copyObjItem); - } - - if (!string.IsNullOrEmpty(node.Predicate)) - { - var copyPredItem = new MenuItem { Header = "Copy Predicate" }; - copyPredItem.Click += async (_, _) => await SetClipboardTextAsync(node.Predicate!); - menu.Items.Add(copyPredItem); - } - - if (!string.IsNullOrEmpty(node.SeekPredicates)) - { - var copySeekItem = new MenuItem { Header = "Copy Seek Predicate" }; - copySeekItem.Click += async (_, _) => await SetClipboardTextAsync(node.SeekPredicates!); - menu.Items.Add(copySeekItem); - } - - // Schema lookup items (Show Indexes, Show Table Definition) - AddSchemaMenuItems(menu, node); - - return menu; - } - - private ContextMenu BuildCanvasContextMenu() - { - var menu = new ContextMenu(); - - // Zoom - var zoomInItem = new MenuItem { Header = "Zoom In" }; - zoomInItem.Click += (_, _) => SetZoom(_zoomLevel + ZoomStep); - menu.Items.Add(zoomInItem); - - var zoomOutItem = new MenuItem { Header = "Zoom Out" }; - zoomOutItem.Click += (_, _) => SetZoom(_zoomLevel - ZoomStep); - menu.Items.Add(zoomOutItem); - - var fitItem = new MenuItem { Header = "Fit to View" }; - fitItem.Click += ZoomFit_Click; - menu.Items.Add(fitItem); - - menu.Items.Add(new Separator()); - - // Advice - var humanAdviceItem = new MenuItem { Header = "Human Advice" }; - humanAdviceItem.Click += (_, _) => HumanAdviceRequested?.Invoke(this, EventArgs.Empty); - menu.Items.Add(humanAdviceItem); - - var robotAdviceItem = new MenuItem { Header = "Robot Advice" }; - robotAdviceItem.Click += (_, _) => RobotAdviceRequested?.Invoke(this, EventArgs.Empty); - menu.Items.Add(robotAdviceItem); - - menu.Items.Add(new Separator()); - - // Repro & Save - var copyReproItem = new MenuItem { Header = "Copy Repro Script" }; - copyReproItem.Click += (_, _) => CopyReproRequested?.Invoke(this, EventArgs.Empty); - menu.Items.Add(copyReproItem); - - var saveItem = new MenuItem { Header = "Save .sqlplan" }; - saveItem.Click += SavePlan_Click; - menu.Items.Add(saveItem); - - return menu; - } - - private async System.Threading.Tasks.Task SetClipboardTextAsync(string text) - { - var topLevel = TopLevel.GetTopLevel(this); - if (topLevel?.Clipboard != null) - await topLevel.Clipboard.SetTextAsync(text); - } - - private void ShowPropertiesPanel(PlanNode node) - { - PropertiesContent.Children.Clear(); - _sectionLabelColumns.Clear(); - _currentSectionGrid = null; - _currentSectionRowIndex = 0; - - // Header - var headerText = node.PhysicalOp; - if (node.LogicalOp != node.PhysicalOp && !string.IsNullOrEmpty(node.LogicalOp) - && !node.PhysicalOp.Contains(node.LogicalOp, StringComparison.OrdinalIgnoreCase)) - headerText += $" ({node.LogicalOp})"; - PropertiesHeader.Text = headerText; - PropertiesSubHeader.Text = $"Node ID: {node.NodeId}"; - - // === General Section === - AddPropertySection("General"); - AddPropertyRow("Physical Operation", node.PhysicalOp); - AddPropertyRow("Logical Operation", node.LogicalOp); - AddPropertyRow("Node ID", $"{node.NodeId}"); - if (!string.IsNullOrEmpty(node.ExecutionMode)) - AddPropertyRow("Execution Mode", node.ExecutionMode); - if (!string.IsNullOrEmpty(node.ActualExecutionMode) && node.ActualExecutionMode != node.ExecutionMode) - AddPropertyRow("Actual Exec Mode", node.ActualExecutionMode); - AddPropertyRow("Parallel", node.Parallel ? "True" : "False"); - if (node.Partitioned) - AddPropertyRow("Partitioned", "True"); - if (node.EstimatedDOP > 0) - AddPropertyRow("Estimated DOP", $"{node.EstimatedDOP}"); - - // Scan/seek-related properties - if (!string.IsNullOrEmpty(node.FullObjectName)) - { - AddPropertyRow("Ordered", node.Ordered ? "True" : "False"); - if (!string.IsNullOrEmpty(node.ScanDirection)) - AddPropertyRow("Scan Direction", node.ScanDirection); - AddPropertyRow("Forced Index", node.ForcedIndex ? "True" : "False"); - AddPropertyRow("ForceScan", node.ForceScan ? "True" : "False"); - AddPropertyRow("ForceSeek", node.ForceSeek ? "True" : "False"); - AddPropertyRow("NoExpandHint", node.NoExpandHint ? "True" : "False"); - if (node.Lookup) - AddPropertyRow("Lookup", "True"); - if (node.DynamicSeek) - AddPropertyRow("Dynamic Seek", "True"); - } - - if (!string.IsNullOrEmpty(node.StorageType)) - AddPropertyRow("Storage", node.StorageType); - if (node.IsAdaptive) - AddPropertyRow("Adaptive", "True"); - if (node.SpillOccurredDetail) - AddPropertyRow("Spill Occurred", "True"); - - // === Object Section === - if (!string.IsNullOrEmpty(node.FullObjectName)) - { - AddPropertySection("Object"); - AddPropertyRow("Full Name", node.FullObjectName, isCode: true); - if (!string.IsNullOrEmpty(node.ServerName)) - AddPropertyRow("Server", node.ServerName); - if (!string.IsNullOrEmpty(node.DatabaseName)) - AddPropertyRow("Database", node.DatabaseName); - if (!string.IsNullOrEmpty(node.ObjectAlias)) - AddPropertyRow("Alias", node.ObjectAlias); - if (!string.IsNullOrEmpty(node.IndexName)) - AddPropertyRow("Index", node.IndexName); - if (!string.IsNullOrEmpty(node.IndexKind)) - AddPropertyRow("Index Kind", node.IndexKind); - if (node.FilteredIndex) - AddPropertyRow("Filtered Index", "True"); - if (node.TableReferenceId > 0) - AddPropertyRow("Table Ref Id", $"{node.TableReferenceId}"); - } - - // === Operator Details Section === - var hasOperatorDetails = !string.IsNullOrEmpty(node.OrderBy) - || !string.IsNullOrEmpty(node.TopExpression) - || !string.IsNullOrEmpty(node.GroupBy) - || !string.IsNullOrEmpty(node.PartitionColumns) - || !string.IsNullOrEmpty(node.HashKeys) - || !string.IsNullOrEmpty(node.SegmentColumn) - || !string.IsNullOrEmpty(node.DefinedValues) - || !string.IsNullOrEmpty(node.OuterReferences) - || !string.IsNullOrEmpty(node.InnerSideJoinColumns) - || !string.IsNullOrEmpty(node.OuterSideJoinColumns) - || !string.IsNullOrEmpty(node.ActionColumn) - || node.ManyToMany || node.PhysicalOp == "Merge Join" || node.BitmapCreator - || node.SortDistinct || node.StartupExpression - || node.NLOptimized || node.WithOrderedPrefetch || node.WithUnorderedPrefetch - || node.WithTies || node.Remoting || node.LocalParallelism - || node.SpoolStack || node.DMLRequestSort || node.NonClusteredIndexCount > 0 - || !string.IsNullOrEmpty(node.OffsetExpression) || node.TopRows > 0 - || !string.IsNullOrEmpty(node.ConstantScanValues) - || !string.IsNullOrEmpty(node.UdxUsedColumns); - - if (hasOperatorDetails) - { - AddPropertySection("Operator Details"); - if (!string.IsNullOrEmpty(node.OrderBy)) - AddPropertyRow("Order By", node.OrderBy, isCode: true); - if (!string.IsNullOrEmpty(node.TopExpression)) - { - var topText = node.TopExpression; - if (node.IsPercent) topText += " PERCENT"; - if (node.WithTies) topText += " WITH TIES"; - AddPropertyRow("Top", topText); - } - if (node.SortDistinct) - AddPropertyRow("Distinct Sort", "True"); - if (node.StartupExpression) - AddPropertyRow("Startup Expression", "True"); - if (node.NLOptimized) - AddPropertyRow("Optimized", "True"); - if (node.WithOrderedPrefetch) - AddPropertyRow("Ordered Prefetch", "True"); - if (node.WithUnorderedPrefetch) - AddPropertyRow("Unordered Prefetch", "True"); - if (node.BitmapCreator) - AddPropertyRow("Bitmap Creator", "True"); - if (node.Remoting) - AddPropertyRow("Remoting", "True"); - if (node.LocalParallelism) - AddPropertyRow("Local Parallelism", "True"); - if (!string.IsNullOrEmpty(node.GroupBy)) - AddPropertyRow("Group By", node.GroupBy, isCode: true); - if (!string.IsNullOrEmpty(node.PartitionColumns)) - AddPropertyRow("Partition Columns", node.PartitionColumns, isCode: true); - if (!string.IsNullOrEmpty(node.HashKeys)) - AddPropertyRow("Hash Keys", node.HashKeys, isCode: true); - if (!string.IsNullOrEmpty(node.OffsetExpression)) - AddPropertyRow("Offset", node.OffsetExpression); - if (node.TopRows > 0) - AddPropertyRow("Rows", $"{node.TopRows}"); - if (node.SpoolStack) - AddPropertyRow("Stack Spool", "True"); - if (node.PrimaryNodeId > 0) - AddPropertyRow("Primary Node Id", $"{node.PrimaryNodeId}"); - if (node.DMLRequestSort) - AddPropertyRow("DML Request Sort", "True"); - if (node.NonClusteredIndexCount > 0) - { - AddPropertyRow("NC Indexes Maintained", $"{node.NonClusteredIndexCount}"); - foreach (var ixName in node.NonClusteredIndexNames) - AddPropertyRow("", ixName, isCode: true); - } - if (!string.IsNullOrEmpty(node.ActionColumn)) - AddPropertyRow("Action Column", node.ActionColumn, isCode: true); - if (!string.IsNullOrEmpty(node.SegmentColumn)) - AddPropertyRow("Segment Column", node.SegmentColumn, isCode: true); - if (!string.IsNullOrEmpty(node.DefinedValues)) - AddPropertyRow("Defined Values", node.DefinedValues, isCode: true); - if (!string.IsNullOrEmpty(node.OuterReferences)) - AddPropertyRow("Outer References", node.OuterReferences, isCode: true); - if (!string.IsNullOrEmpty(node.InnerSideJoinColumns)) - AddPropertyRow("Inner Join Cols", node.InnerSideJoinColumns, isCode: true); - if (!string.IsNullOrEmpty(node.OuterSideJoinColumns)) - AddPropertyRow("Outer Join Cols", node.OuterSideJoinColumns, isCode: true); - if (node.PhysicalOp == "Merge Join") - AddPropertyRow("Many to Many", node.ManyToMany ? "Yes" : "No"); - else if (node.ManyToMany) - AddPropertyRow("Many to Many", "Yes"); - if (!string.IsNullOrEmpty(node.ConstantScanValues)) - AddPropertyRow("Values", node.ConstantScanValues, isCode: true); - if (!string.IsNullOrEmpty(node.UdxUsedColumns)) - AddPropertyRow("UDX Columns", node.UdxUsedColumns, isCode: true); - if (node.RowCount) - AddPropertyRow("Row Count", "True"); - if (node.ForceSeekColumnCount > 0) - AddPropertyRow("ForceSeek Columns", $"{node.ForceSeekColumnCount}"); - if (!string.IsNullOrEmpty(node.PartitionId)) - AddPropertyRow("Partition Id", node.PartitionId, isCode: true); - if (node.IsStarJoin) - AddPropertyRow("Star Join Root", "True"); - if (!string.IsNullOrEmpty(node.StarJoinOperationType)) - AddPropertyRow("Star Join Type", node.StarJoinOperationType); - if (!string.IsNullOrEmpty(node.ProbeColumn)) - AddPropertyRow("Probe Column", node.ProbeColumn, isCode: true); - if (node.InRow) - AddPropertyRow("In-Row", "True"); - if (node.ComputeSequence) - AddPropertyRow("Compute Sequence", "True"); - if (node.RollupHighestLevel > 0) - AddPropertyRow("Rollup Highest Level", $"{node.RollupHighestLevel}"); - if (node.RollupLevels.Count > 0) - AddPropertyRow("Rollup Levels", string.Join(", ", node.RollupLevels)); - if (!string.IsNullOrEmpty(node.TvfParameters)) - AddPropertyRow("TVF Parameters", node.TvfParameters, isCode: true); - if (!string.IsNullOrEmpty(node.OriginalActionColumn)) - AddPropertyRow("Original Action Col", node.OriginalActionColumn, isCode: true); - if (!string.IsNullOrEmpty(node.TieColumns)) - AddPropertyRow("WITH TIES Columns", node.TieColumns, isCode: true); - if (!string.IsNullOrEmpty(node.UdxName)) - AddPropertyRow("UDX Name", node.UdxName); - if (node.GroupExecuted) - AddPropertyRow("Group Executed", "True"); - if (node.RemoteDataAccess) - AddPropertyRow("Remote Data Access", "True"); - if (node.OptimizedHalloweenProtectionUsed) - AddPropertyRow("Halloween Protection", "True"); - if (node.StatsCollectionId > 0) - AddPropertyRow("Stats Collection Id", $"{node.StatsCollectionId}"); - } - - // === Scalar UDFs === - if (node.ScalarUdfs.Count > 0) - { - AddPropertySection("Scalar UDFs"); - foreach (var udf in node.ScalarUdfs) - { - var udfDetail = udf.FunctionName; - if (udf.IsClrFunction) - { - udfDetail += " (CLR)"; - if (!string.IsNullOrEmpty(udf.ClrAssembly)) - udfDetail += $"\n Assembly: {udf.ClrAssembly}"; - if (!string.IsNullOrEmpty(udf.ClrClass)) - udfDetail += $"\n Class: {udf.ClrClass}"; - if (!string.IsNullOrEmpty(udf.ClrMethod)) - udfDetail += $"\n Method: {udf.ClrMethod}"; - } - AddPropertyRow("UDF", udfDetail, isCode: true); - } - } - - // === Named Parameters (IndexScan) === - if (node.NamedParameters.Count > 0) - { - AddPropertySection("Named Parameters"); - foreach (var np in node.NamedParameters) - AddPropertyRow(np.Name, np.ScalarString ?? "", isCode: true); - } - - // === Per-Operator Indexed Views === - if (node.OperatorIndexedViews.Count > 0) - { - AddPropertySection("Operator Indexed Views"); - foreach (var iv in node.OperatorIndexedViews) - AddPropertyRow("View", iv, isCode: true); - } - - // === Suggested Index (Eager Spool) === - if (!string.IsNullOrEmpty(node.SuggestedIndex)) - { - AddPropertySection("Suggested Index"); - AddPropertyRow("CREATE INDEX", node.SuggestedIndex, isCode: true); - } - - // === Remote Operator === - if (!string.IsNullOrEmpty(node.RemoteDestination) || !string.IsNullOrEmpty(node.RemoteSource) - || !string.IsNullOrEmpty(node.RemoteObject) || !string.IsNullOrEmpty(node.RemoteQuery)) - { - AddPropertySection("Remote Operator"); - if (!string.IsNullOrEmpty(node.RemoteDestination)) - AddPropertyRow("Destination", node.RemoteDestination); - if (!string.IsNullOrEmpty(node.RemoteSource)) - AddPropertyRow("Source", node.RemoteSource); - if (!string.IsNullOrEmpty(node.RemoteObject)) - AddPropertyRow("Object", node.RemoteObject, isCode: true); - if (!string.IsNullOrEmpty(node.RemoteQuery)) - AddPropertyRow("Query", node.RemoteQuery, isCode: true); - } - - // === Foreign Key References Section === - if (node.ForeignKeyReferencesCount > 0 || node.NoMatchingIndexCount > 0 || node.PartialMatchingIndexCount > 0) - { - AddPropertySection("Foreign Key References"); - if (node.ForeignKeyReferencesCount > 0) - AddPropertyRow("FK References", $"{node.ForeignKeyReferencesCount}"); - if (node.NoMatchingIndexCount > 0) - AddPropertyRow("No Matching Index", $"{node.NoMatchingIndexCount}"); - if (node.PartialMatchingIndexCount > 0) - AddPropertyRow("Partial Match Index", $"{node.PartialMatchingIndexCount}"); - } - - // === Adaptive Join Section === - if (node.IsAdaptive) - { - AddPropertySection("Adaptive Join"); - if (!string.IsNullOrEmpty(node.EstimatedJoinType)) - AddPropertyRow("Est. Join Type", node.EstimatedJoinType); - if (!string.IsNullOrEmpty(node.ActualJoinType)) - AddPropertyRow("Actual Join Type", node.ActualJoinType); - if (node.AdaptiveThresholdRows > 0) - AddPropertyRow("Threshold Rows", $"{node.AdaptiveThresholdRows:N1}"); - } - - // === Estimated Costs Section === - AddPropertySection("Estimated Costs"); - AddPropertyRow("Operator Cost", $"{node.EstimatedOperatorCost:F6} ({node.CostPercent}%)"); - AddPropertyRow("Subtree Cost", $"{node.EstimatedTotalSubtreeCost:F6}"); - AddPropertyRow("I/O Cost", $"{node.EstimateIO:F6}"); - AddPropertyRow("CPU Cost", $"{node.EstimateCPU:F6}"); - - // === Estimated Rows Section === - AddPropertySection("Estimated Rows"); - var estExecs = 1 + node.EstimateRebinds; - AddPropertyRow("Est. Executions", $"{estExecs:N0}"); - AddPropertyRow("Est. Rows Per Exec", $"{node.EstimateRows:N1}"); - AddPropertyRow("Est. Rows All Execs", $"{node.EstimateRows * Math.Max(1, estExecs):N1}"); - if (node.EstimatedRowsRead > 0) - AddPropertyRow("Est. Rows to Read", $"{node.EstimatedRowsRead:N1}"); - if (node.EstimateRowsWithoutRowGoal > 0) - AddPropertyRow("Est. Rows (No Row Goal)", $"{node.EstimateRowsWithoutRowGoal:N1}"); - if (node.TableCardinality > 0) - AddPropertyRow("Table Cardinality", $"{node.TableCardinality:N0}"); - AddPropertyRow("Avg Row Size", $"{node.EstimatedRowSize} B"); - AddPropertyRow("Est. Rebinds", $"{node.EstimateRebinds:N1}"); - AddPropertyRow("Est. Rewinds", $"{node.EstimateRewinds:N1}"); - - // === Actual Stats Section (if actual plan) === - if (node.HasActualStats) - { - AddPropertySection("Actual Statistics"); - AddPropertyRow("Actual Rows", $"{node.ActualRows:N0}"); - if (node.PerThreadStats.Count > 1) - foreach (var t in node.PerThreadStats) - AddPropertyRow($" Thread {t.ThreadId}", $"{t.ActualRows:N0}", indent: true); - if (node.ActualRowsRead > 0) - { - AddPropertyRow("Actual Rows Read", $"{node.ActualRowsRead:N0}"); - if (node.PerThreadStats.Count > 1) - foreach (var t in node.PerThreadStats.Where(t => t.ActualRowsRead > 0)) - AddPropertyRow($" Thread {t.ThreadId}", $"{t.ActualRowsRead:N0}", indent: true); - } - AddPropertyRow("Actual Executions", $"{node.ActualExecutions:N0}"); - if (node.PerThreadStats.Count > 1) - foreach (var t in node.PerThreadStats) - AddPropertyRow($" Thread {t.ThreadId}", $"{t.ActualExecutions:N0}", indent: true); - if (node.ActualRebinds > 0) - AddPropertyRow("Actual Rebinds", $"{node.ActualRebinds:N0}"); - if (node.ActualRewinds > 0) - AddPropertyRow("Actual Rewinds", $"{node.ActualRewinds:N0}"); - - // Runtime partition summary - if (node.PartitionsAccessed > 0) - { - AddPropertyRow("Partitions Accessed", $"{node.PartitionsAccessed}"); - if (!string.IsNullOrEmpty(node.PartitionRanges)) - AddPropertyRow("Partition Ranges", node.PartitionRanges); - } - - // Timing - if (node.ActualElapsedMs > 0 || node.ActualCPUMs > 0 - || node.UdfCpuTimeMs > 0 || node.UdfElapsedTimeMs > 0) - { - AddPropertySection("Actual Timing"); - if (node.ActualElapsedMs > 0) - { - AddPropertyRow("Elapsed Time", $"{node.ActualElapsedMs:N0} ms"); - if (node.PerThreadStats.Count > 1) - foreach (var t in node.PerThreadStats.Where(t => t.ActualElapsedMs > 0)) - AddPropertyRow($" Thread {t.ThreadId}", $"{t.ActualElapsedMs:N0} ms", indent: true); - } - if (node.ActualCPUMs > 0) - { - AddPropertyRow("CPU Time", $"{node.ActualCPUMs:N0} ms"); - if (node.PerThreadStats.Count > 1) - foreach (var t in node.PerThreadStats.Where(t => t.ActualCPUMs > 0)) - AddPropertyRow($" Thread {t.ThreadId}", $"{t.ActualCPUMs:N0} ms", indent: true); - } - if (node.UdfElapsedTimeMs > 0) - AddPropertyRow("UDF Elapsed", $"{node.UdfElapsedTimeMs:N0} ms"); - if (node.UdfCpuTimeMs > 0) - AddPropertyRow("UDF CPU", $"{node.UdfCpuTimeMs:N0} ms"); - } - - // I/O - var hasIo = node.ActualLogicalReads > 0 || node.ActualPhysicalReads > 0 - || node.ActualScans > 0 || node.ActualReadAheads > 0 - || node.ActualSegmentReads > 0 || node.ActualSegmentSkips > 0; - if (hasIo) - { - AddPropertySection("Actual I/O"); - AddPropertyRow("Logical Reads", $"{node.ActualLogicalReads:N0}"); - if (node.PerThreadStats.Count > 1) - foreach (var t in node.PerThreadStats.Where(t => t.ActualLogicalReads > 0)) - AddPropertyRow($" Thread {t.ThreadId}", $"{t.ActualLogicalReads:N0}", indent: true); - if (node.ActualPhysicalReads > 0) - { - AddPropertyRow("Physical Reads", $"{node.ActualPhysicalReads:N0}"); - if (node.PerThreadStats.Count > 1) - foreach (var t in node.PerThreadStats.Where(t => t.ActualPhysicalReads > 0)) - AddPropertyRow($" Thread {t.ThreadId}", $"{t.ActualPhysicalReads:N0}", indent: true); - } - if (node.ActualScans > 0) - { - AddPropertyRow("Scans", $"{node.ActualScans:N0}"); - if (node.PerThreadStats.Count > 1) - foreach (var t in node.PerThreadStats.Where(t => t.ActualScans > 0)) - AddPropertyRow($" Thread {t.ThreadId}", $"{t.ActualScans:N0}", indent: true); - } - if (node.ActualReadAheads > 0) - { - AddPropertyRow("Read-Ahead Reads", $"{node.ActualReadAheads:N0}"); - if (node.PerThreadStats.Count > 1) - foreach (var t in node.PerThreadStats.Where(t => t.ActualReadAheads > 0)) - AddPropertyRow($" Thread {t.ThreadId}", $"{t.ActualReadAheads:N0}", indent: true); - } - if (node.ActualSegmentReads > 0) - AddPropertyRow("Segment Reads", $"{node.ActualSegmentReads:N0}"); - if (node.ActualSegmentSkips > 0) - AddPropertyRow("Segment Skips", $"{node.ActualSegmentSkips:N0}"); - } - - // LOB I/O - var hasLobIo = node.ActualLobLogicalReads > 0 || node.ActualLobPhysicalReads > 0 - || node.ActualLobReadAheads > 0; - if (hasLobIo) - { - AddPropertySection("Actual LOB I/O"); - if (node.ActualLobLogicalReads > 0) - AddPropertyRow("LOB Logical Reads", $"{node.ActualLobLogicalReads:N0}"); - if (node.ActualLobPhysicalReads > 0) - AddPropertyRow("LOB Physical Reads", $"{node.ActualLobPhysicalReads:N0}"); - if (node.ActualLobReadAheads > 0) - AddPropertyRow("LOB Read-Aheads", $"{node.ActualLobReadAheads:N0}"); - } - } - - // === Predicates Section === - var hasPredicates = !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) - || node.GuessedSelectivity; - if (hasPredicates) - { - AddPropertySection("Predicates"); - if (!string.IsNullOrEmpty(node.SeekPredicates)) - AddPropertyRow("Seek Predicate", node.SeekPredicates, isCode: true); - if (!string.IsNullOrEmpty(node.Predicate)) - AddPropertyRow("Predicate", node.Predicate, isCode: true); - if (!string.IsNullOrEmpty(node.HashKeysBuild)) - AddPropertyRow("Hash Keys (Build)", node.HashKeysBuild, isCode: true); - if (!string.IsNullOrEmpty(node.HashKeysProbe)) - AddPropertyRow("Hash Keys (Probe)", node.HashKeysProbe, isCode: true); - if (!string.IsNullOrEmpty(node.BuildResidual)) - AddPropertyRow("Build Residual", node.BuildResidual, isCode: true); - if (!string.IsNullOrEmpty(node.ProbeResidual)) - AddPropertyRow("Probe Residual", node.ProbeResidual, isCode: true); - if (!string.IsNullOrEmpty(node.MergeResidual)) - AddPropertyRow("Merge Residual", node.MergeResidual, isCode: true); - if (!string.IsNullOrEmpty(node.PassThru)) - AddPropertyRow("Pass Through", node.PassThru, isCode: true); - if (!string.IsNullOrEmpty(node.SetPredicate)) - AddPropertyRow("Set Predicate", node.SetPredicate, isCode: true); - if (node.GuessedSelectivity) - AddPropertyRow("Guessed Selectivity", "True (optimizer guessed, no statistics)"); - } - - // === Output Columns === - if (!string.IsNullOrEmpty(node.OutputColumns)) - { - AddPropertySection("Output"); - AddPropertyRow("Columns", node.OutputColumns, isCode: true); - } - - // === Memory === - if (node.MemoryGrantKB > 0 || node.DesiredMemoryKB > 0 || node.MaxUsedMemoryKB > 0 - || node.MemoryFractionInput > 0 || node.MemoryFractionOutput > 0 - || node.InputMemoryGrantKB > 0 || node.OutputMemoryGrantKB > 0 || node.UsedMemoryGrantKB > 0) - { - AddPropertySection("Memory"); - if (node.MemoryGrantKB > 0) AddPropertyRow("Granted", $"{node.MemoryGrantKB:N0} KB"); - if (node.DesiredMemoryKB > 0) AddPropertyRow("Desired", $"{node.DesiredMemoryKB:N0} KB"); - if (node.MaxUsedMemoryKB > 0) AddPropertyRow("Max Used", $"{node.MaxUsedMemoryKB:N0} KB"); - if (node.InputMemoryGrantKB > 0) AddPropertyRow("Input Grant", $"{node.InputMemoryGrantKB:N0} KB"); - if (node.OutputMemoryGrantKB > 0) AddPropertyRow("Output Grant", $"{node.OutputMemoryGrantKB:N0} KB"); - if (node.UsedMemoryGrantKB > 0) AddPropertyRow("Used Grant", $"{node.UsedMemoryGrantKB:N0} KB"); - if (node.MemoryFractionInput > 0) AddPropertyRow("Fraction Input", $"{node.MemoryFractionInput:F4}"); - if (node.MemoryFractionOutput > 0) AddPropertyRow("Fraction Output", $"{node.MemoryFractionOutput:F4}"); - } - - // === Root node only: statement-level sections === - if (node.Parent == null && _currentStatement != null) - { - var s = _currentStatement; - - // === Statement Text === - if (!string.IsNullOrEmpty(s.StatementText) || !string.IsNullOrEmpty(s.StmtUseDatabaseName)) - { - AddPropertySection("Statement"); - if (!string.IsNullOrEmpty(s.StatementText)) - AddPropertyRow("Text", s.StatementText, isCode: true); - if (!string.IsNullOrEmpty(s.ParameterizedText) && s.ParameterizedText != s.StatementText) - AddPropertyRow("Parameterized", s.ParameterizedText, isCode: true); - if (!string.IsNullOrEmpty(s.StmtUseDatabaseName)) - AddPropertyRow("USE Database", s.StmtUseDatabaseName); - } - - // === Cursor Info === - if (!string.IsNullOrEmpty(s.CursorName)) - { - AddPropertySection("Cursor Info"); - AddPropertyRow("Cursor Name", s.CursorName); - if (!string.IsNullOrEmpty(s.CursorActualType)) - AddPropertyRow("Actual Type", s.CursorActualType); - if (!string.IsNullOrEmpty(s.CursorRequestedType)) - AddPropertyRow("Requested Type", s.CursorRequestedType); - if (!string.IsNullOrEmpty(s.CursorConcurrency)) - AddPropertyRow("Concurrency", s.CursorConcurrency); - AddPropertyRow("Forward Only", s.CursorForwardOnly ? "True" : "False"); - } - - // === Statement Memory Grant === - if (s.MemoryGrant != null) - { - var mg = s.MemoryGrant; - AddPropertySection("Memory Grant Info"); - AddPropertyRow("Granted", $"{mg.GrantedMemoryKB:N0} KB"); - AddPropertyRow("Max Used", $"{mg.MaxUsedMemoryKB:N0} KB"); - AddPropertyRow("Requested", $"{mg.RequestedMemoryKB:N0} KB"); - AddPropertyRow("Desired", $"{mg.DesiredMemoryKB:N0} KB"); - AddPropertyRow("Required", $"{mg.RequiredMemoryKB:N0} KB"); - AddPropertyRow("Serial Required", $"{mg.SerialRequiredMemoryKB:N0} KB"); - AddPropertyRow("Serial Desired", $"{mg.SerialDesiredMemoryKB:N0} KB"); - if (mg.GrantWaitTimeMs > 0) - AddPropertyRow("Grant Wait Time", $"{mg.GrantWaitTimeMs:N0} ms"); - if (mg.LastRequestedMemoryKB > 0) - AddPropertyRow("Last Requested", $"{mg.LastRequestedMemoryKB:N0} KB"); - if (!string.IsNullOrEmpty(mg.IsMemoryGrantFeedbackAdjusted)) - AddPropertyRow("Feedback Adjusted", mg.IsMemoryGrantFeedbackAdjusted); - } - - // === Statement Info === - AddPropertySection("Statement Info"); - if (!string.IsNullOrEmpty(s.StatementOptmLevel)) - AddPropertyRow("Optimization Level", s.StatementOptmLevel); - if (!string.IsNullOrEmpty(s.StatementOptmEarlyAbortReason)) - AddPropertyRow("Early Abort Reason", s.StatementOptmEarlyAbortReason); - if (s.CardinalityEstimationModelVersion > 0) - AddPropertyRow("CE Model Version", $"{s.CardinalityEstimationModelVersion}"); - if (s.DegreeOfParallelism > 0) - AddPropertyRow("DOP", $"{s.DegreeOfParallelism}"); - if (s.EffectiveDOP > 0) - AddPropertyRow("Effective DOP", $"{s.EffectiveDOP}"); - if (!string.IsNullOrEmpty(s.DOPFeedbackAdjusted)) - AddPropertyRow("DOP Feedback", s.DOPFeedbackAdjusted); - if (!string.IsNullOrEmpty(s.NonParallelPlanReason)) - AddPropertyRow("Non-Parallel Reason", s.NonParallelPlanReason); - if (s.MaxQueryMemoryKB > 0) - AddPropertyRow("Max Query Memory", $"{s.MaxQueryMemoryKB:N0} KB"); - if (s.QueryPlanMemoryGrantKB > 0) - AddPropertyRow("QueryPlan Memory Grant", $"{s.QueryPlanMemoryGrantKB:N0} KB"); - AddPropertyRow("Compile Time", $"{s.CompileTimeMs:N0} ms"); - AddPropertyRow("Compile CPU", $"{s.CompileCPUMs:N0} ms"); - AddPropertyRow("Compile Memory", $"{s.CompileMemoryKB:N0} KB"); - if (s.CachedPlanSizeKB > 0) - AddPropertyRow("Cached Plan Size", $"{s.CachedPlanSizeKB:N0} KB"); - AddPropertyRow("Retrieved From Cache", s.RetrievedFromCache ? "True" : "False"); - AddPropertyRow("Batch Mode On RowStore", s.BatchModeOnRowStoreUsed ? "True" : "False"); - AddPropertyRow("Security Policy", s.SecurityPolicyApplied ? "True" : "False"); - AddPropertyRow("Parameterization Type", $"{s.StatementParameterizationType}"); - if (!string.IsNullOrEmpty(s.QueryHash)) - AddPropertyRow("Query Hash", s.QueryHash, isCode: true); - if (!string.IsNullOrEmpty(s.QueryPlanHash)) - AddPropertyRow("Plan Hash", s.QueryPlanHash, isCode: true); - if (!string.IsNullOrEmpty(s.StatementSqlHandle)) - AddPropertyRow("SQL Handle", s.StatementSqlHandle, isCode: true); - AddPropertyRow("DB Settings Id", $"{s.DatabaseContextSettingsId}"); - AddPropertyRow("Parent Object Id", $"{s.ParentObjectId}"); - - // Plan Guide - if (!string.IsNullOrEmpty(s.PlanGuideName)) - { - AddPropertyRow("Plan Guide", s.PlanGuideName); - if (!string.IsNullOrEmpty(s.PlanGuideDB)) - AddPropertyRow("Plan Guide DB", s.PlanGuideDB); - } - if (s.UsePlan) - AddPropertyRow("USE PLAN", "True"); - - // Query Store Hints - if (s.QueryStoreStatementHintId > 0) - { - AddPropertyRow("QS Hint Id", $"{s.QueryStoreStatementHintId}"); - if (!string.IsNullOrEmpty(s.QueryStoreStatementHintText)) - AddPropertyRow("QS Hint", s.QueryStoreStatementHintText, isCode: true); - if (!string.IsNullOrEmpty(s.QueryStoreStatementHintSource)) - AddPropertyRow("QS Hint Source", s.QueryStoreStatementHintSource); - } - - // === Feature Flags === - if (s.ContainsInterleavedExecutionCandidates || s.ContainsInlineScalarTsqlUdfs - || s.ContainsLedgerTables || s.ExclusiveProfileTimeActive || s.QueryCompilationReplay > 0 - || s.QueryVariantID > 0) - { - AddPropertySection("Feature Flags"); - if (s.ContainsInterleavedExecutionCandidates) - AddPropertyRow("Interleaved Execution", "True"); - if (s.ContainsInlineScalarTsqlUdfs) - AddPropertyRow("Inline Scalar UDFs", "True"); - if (s.ContainsLedgerTables) - AddPropertyRow("Ledger Tables", "True"); - if (s.ExclusiveProfileTimeActive) - AddPropertyRow("Exclusive Profile Time", "True"); - if (s.QueryCompilationReplay > 0) - AddPropertyRow("Compilation Replay", $"{s.QueryCompilationReplay}"); - if (s.QueryVariantID > 0) - AddPropertyRow("Query Variant ID", $"{s.QueryVariantID}"); - } - - // === PSP Dispatcher === - if (s.Dispatcher != null) - { - AddPropertySection("PSP Dispatcher"); - if (!string.IsNullOrEmpty(s.DispatcherPlanHandle)) - AddPropertyRow("Plan Handle", s.DispatcherPlanHandle, isCode: true); - foreach (var psp in s.Dispatcher.ParameterSensitivePredicates) - { - var range = $"[{psp.LowBoundary:N0} — {psp.HighBoundary:N0}]"; - var predText = psp.PredicateText ?? ""; - AddPropertyRow("Predicate", $"{predText} {range}", isCode: true); - foreach (var stat in psp.Statistics) - { - var statLabel = !string.IsNullOrEmpty(stat.TableName) - ? $" {stat.TableName}.{stat.StatisticsName}" - : $" {stat.StatisticsName}"; - AddPropertyRow(statLabel, $"Modified: {stat.ModificationCount:N0}, Sampled: {stat.SamplingPercent:F1}%", indent: true); - } - } - foreach (var opt in s.Dispatcher.OptionalParameterPredicates) - { - if (!string.IsNullOrEmpty(opt.PredicateText)) - AddPropertyRow("Optional Predicate", opt.PredicateText, isCode: true); - } - } - - // === Cardinality Feedback === - if (s.CardinalityFeedback.Count > 0) - { - AddPropertySection("Cardinality Feedback"); - foreach (var cf in s.CardinalityFeedback) - AddPropertyRow($"Node {cf.Key}", $"{cf.Value:N0}"); - } - - // === Optimization Replay === - if (!string.IsNullOrEmpty(s.OptimizationReplayScript)) - { - AddPropertySection("Optimization Replay"); - AddPropertyRow("Script", s.OptimizationReplayScript, isCode: true); - } - - // === Template Plan Guide === - if (!string.IsNullOrEmpty(s.TemplatePlanGuideName)) - { - AddPropertyRow("Template Plan Guide", s.TemplatePlanGuideName); - if (!string.IsNullOrEmpty(s.TemplatePlanGuideDB)) - AddPropertyRow("Template Guide DB", s.TemplatePlanGuideDB); - } - - // === Handles === - if (!string.IsNullOrEmpty(s.ParameterizedPlanHandle) || !string.IsNullOrEmpty(s.BatchSqlHandle)) - { - AddPropertySection("Handles"); - if (!string.IsNullOrEmpty(s.ParameterizedPlanHandle)) - AddPropertyRow("Parameterized Plan", s.ParameterizedPlanHandle, isCode: true); - if (!string.IsNullOrEmpty(s.BatchSqlHandle)) - AddPropertyRow("Batch SQL Handle", s.BatchSqlHandle, isCode: true); - } - - // === Set Options === - if (s.SetOptions != null) - { - var so = s.SetOptions; - AddPropertySection("Set Options"); - AddPropertyRow("ANSI_NULLS", so.AnsiNulls ? "True" : "False"); - AddPropertyRow("ANSI_PADDING", so.AnsiPadding ? "True" : "False"); - AddPropertyRow("ANSI_WARNINGS", so.AnsiWarnings ? "True" : "False"); - AddPropertyRow("ARITHABORT", so.ArithAbort ? "True" : "False"); - AddPropertyRow("CONCAT_NULL", so.ConcatNullYieldsNull ? "True" : "False"); - AddPropertyRow("NUMERIC_ROUNDABORT", so.NumericRoundAbort ? "True" : "False"); - AddPropertyRow("QUOTED_IDENTIFIER", so.QuotedIdentifier ? "True" : "False"); - } - - // === Optimizer Hardware Properties === - if (s.HardwareProperties != null) - { - var hw = s.HardwareProperties; - AddPropertySection("Hardware Properties"); - AddPropertyRow("Available Memory", $"{hw.EstimatedAvailableMemoryGrant:N0} KB"); - AddPropertyRow("Pages Cached", $"{hw.EstimatedPagesCached:N0}"); - AddPropertyRow("Available DOP", $"{hw.EstimatedAvailableDOP}"); - if (hw.MaxCompileMemory > 0) - AddPropertyRow("Max Compile Memory", $"{hw.MaxCompileMemory:N0} KB"); - } - - // === Plan Version === - if (_currentPlan != null && (!string.IsNullOrEmpty(_currentPlan.BuildVersion) || !string.IsNullOrEmpty(_currentPlan.Build))) - { - AddPropertySection("Plan Version"); - if (!string.IsNullOrEmpty(_currentPlan.BuildVersion)) - AddPropertyRow("Build Version", _currentPlan.BuildVersion); - if (!string.IsNullOrEmpty(_currentPlan.Build)) - AddPropertyRow("Build", _currentPlan.Build); - if (_currentPlan.ClusteredMode) - AddPropertyRow("Clustered Mode", "True"); - } - - // === Optimizer Stats Usage === - if (s.StatsUsage.Count > 0) - { - AddPropertySection("Statistics Used"); - foreach (var stat in s.StatsUsage) - { - var statLabel = !string.IsNullOrEmpty(stat.TableName) - ? $"{stat.TableName}.{stat.StatisticsName}" - : stat.StatisticsName; - var statDetail = $"Modified: {stat.ModificationCount:N0}, Sampled: {stat.SamplingPercent:F1}%"; - if (!string.IsNullOrEmpty(stat.LastUpdate)) - statDetail += $", Updated: {stat.LastUpdate}"; - AddPropertyRow(statLabel, statDetail); - } - } - - // === Parameters === - if (s.Parameters.Count > 0) - { - AddPropertySection("Parameters"); - foreach (var p in s.Parameters) - { - var paramText = p.DataType; - if (!string.IsNullOrEmpty(p.CompiledValue)) - paramText += $", Compiled: {p.CompiledValue}"; - if (!string.IsNullOrEmpty(p.RuntimeValue)) - paramText += $", Runtime: {p.RuntimeValue}"; - AddPropertyRow(p.Name, paramText); - } - } - - // === Query Time Stats (actual plans) === - if (s.QueryTimeStats != null) - { - AddPropertySection("Query Time Stats"); - AddPropertyRow("CPU Time", $"{s.QueryTimeStats.CpuTimeMs:N0} ms"); - AddPropertyRow("Elapsed Time", $"{s.QueryTimeStats.ElapsedTimeMs:N0} ms"); - if (s.QueryUdfCpuTimeMs > 0) - AddPropertyRow("UDF CPU Time", $"{s.QueryUdfCpuTimeMs:N0} ms"); - if (s.QueryUdfElapsedTimeMs > 0) - AddPropertyRow("UDF Elapsed Time", $"{s.QueryUdfElapsedTimeMs:N0} ms"); - } - - // === Thread Stats (actual plans) === - if (s.ThreadStats != null) - { - AddPropertySection("Thread Stats"); - AddPropertyRow("Branches", $"{s.ThreadStats.Branches}"); - AddPropertyRow("Used Threads", $"{s.ThreadStats.UsedThreads}"); - var totalReserved = s.ThreadStats.Reservations.Sum(r => r.ReservedThreads); - if (totalReserved > 0) - { - AddPropertyRow("Reserved Threads", $"{totalReserved}"); - if (totalReserved > s.ThreadStats.UsedThreads) - AddPropertyRow("Inactive Threads", $"{totalReserved - s.ThreadStats.UsedThreads}"); - } - foreach (var res in s.ThreadStats.Reservations) - AddPropertyRow($" Node {res.NodeId}", $"{res.ReservedThreads} reserved"); - } - - // === Wait Stats (actual plans) === - if (s.WaitStats.Count > 0) - { - AddPropertySection("Wait Stats"); - foreach (var w in s.WaitStats.OrderByDescending(w => w.WaitTimeMs)) - AddPropertyRow(w.WaitType, $"{w.WaitTimeMs:N0} ms ({w.WaitCount:N0} waits)"); - } - - // === Trace Flags === - if (s.TraceFlags.Count > 0) - { - AddPropertySection("Trace Flags"); - foreach (var tf in s.TraceFlags) - { - var tfLabel = $"TF {tf.Value}"; - var tfDetail = $"{tf.Scope}{(tf.IsCompileTime ? ", Compile-time" : ", Runtime")}"; - AddPropertyRow(tfLabel, tfDetail); - } - } - - // === Indexed Views === - if (s.IndexedViews.Count > 0) - { - AddPropertySection("Indexed Views"); - foreach (var iv in s.IndexedViews) - AddPropertyRow("View", iv, isCode: true); - } - - // === Plan-Level Warnings === - if (s.PlanWarnings.Count > 0) - { - var planWarningsPanel = new StackPanel(); - var sortedPlanWarnings = s.PlanWarnings - .OrderByDescending(w => w.MaxBenefitPercent ?? -1) - .ThenByDescending(w => w.Severity) - .ThenBy(w => w.WarningType); - foreach (var w in sortedPlanWarnings) - { - var warnColor = w.Severity == PlanWarningSeverity.Critical ? "#E57373" - : w.Severity == PlanWarningSeverity.Warning ? "#FFB347" : "#6BB5FF"; - var warnPanel = new StackPanel { Margin = new Thickness(10, 2, 10, 2) }; - var legacyTag = w.IsLegacy ? " [legacy]" : ""; - var planWarnHeader = w.MaxBenefitPercent.HasValue - ? $"\u26A0 {w.WarningType}{legacyTag} \u2014 up to {FormatBenefitPercent(w.MaxBenefitPercent.Value)}% benefit" - : $"\u26A0 {w.WarningType}{legacyTag}"; - warnPanel.Children.Add(new TextBlock - { - Text = planWarnHeader, - FontWeight = FontWeight.SemiBold, - FontSize = 11, - Foreground = new SolidColorBrush(Color.Parse(warnColor)) - }); - warnPanel.Children.Add(new TextBlock - { - Text = w.Message, - FontSize = 11, - Foreground = TooltipFgBrush, - TextWrapping = TextWrapping.Wrap, - Margin = new Thickness(16, 0, 0, 0) - }); - if (!string.IsNullOrEmpty(w.ActionableFix)) - { - warnPanel.Children.Add(new TextBlock - { - Text = w.ActionableFix, - FontSize = 11, - FontStyle = FontStyle.Italic, - Foreground = TooltipFgBrush, - TextWrapping = TextWrapping.Wrap, - Margin = new Thickness(16, 2, 0, 0) - }); - } - planWarningsPanel.Children.Add(warnPanel); - } - - var planWarningsExpander = new Expander - { - IsExpanded = true, - Header = new TextBlock - { - Text = "Plan Warnings", - FontWeight = FontWeight.SemiBold, - FontSize = 11, - Foreground = SectionHeaderBrush - }, - Content = planWarningsPanel, - Margin = new Thickness(0, 2, 0, 0), - Padding = new Thickness(0), - Foreground = SectionHeaderBrush, - Background = new SolidColorBrush(Color.FromArgb(0x18, 0x4F, 0xA3, 0xFF)), - BorderBrush = PropSeparatorBrush, - BorderThickness = new Thickness(0, 0, 0, 1), - HorizontalAlignment = HorizontalAlignment.Stretch, - HorizontalContentAlignment = HorizontalAlignment.Stretch - }; - PropertiesContent.Children.Add(planWarningsExpander); - } - - // === Missing Indexes === - if (s.MissingIndexes.Count > 0) - { - AddPropertySection("Missing Indexes"); - foreach (var mi in s.MissingIndexes) - { - AddPropertyRow($"{mi.Schema}.{mi.Table}", $"Impact: {mi.Impact:F1}%"); - if (!string.IsNullOrEmpty(mi.CreateStatement)) - AddPropertyRow("CREATE INDEX", mi.CreateStatement, isCode: true); - } - } - } - - // === Warnings === - if (node.HasWarnings) - { - var warningsPanel = new StackPanel(); - var sortedNodeWarnings = node.Warnings - .OrderByDescending(w => w.MaxBenefitPercent ?? -1) - .ThenByDescending(w => w.Severity) - .ThenBy(w => w.WarningType); - foreach (var w in sortedNodeWarnings) - { - var warnColor = w.Severity == PlanWarningSeverity.Critical ? "#E57373" - : w.Severity == PlanWarningSeverity.Warning ? "#FFB347" : "#6BB5FF"; - var warnPanel = new StackPanel { Margin = new Thickness(10, 2, 10, 2) }; - var nodeLegacyTag = w.IsLegacy ? " [legacy]" : ""; - var nodeWarnHeader = w.MaxBenefitPercent.HasValue - ? $"\u26A0 {w.WarningType}{nodeLegacyTag} \u2014 up to {FormatBenefitPercent(w.MaxBenefitPercent.Value)}% benefit" - : $"\u26A0 {w.WarningType}{nodeLegacyTag}"; - warnPanel.Children.Add(new TextBlock - { - Text = nodeWarnHeader, - FontWeight = FontWeight.SemiBold, - FontSize = 11, - Foreground = new SolidColorBrush(Color.Parse(warnColor)) - }); - warnPanel.Children.Add(new TextBlock - { - Text = w.Message, - FontSize = 11, - Foreground = TooltipFgBrush, - TextWrapping = TextWrapping.Wrap, - Margin = new Thickness(16, 0, 0, 0) - }); - warningsPanel.Children.Add(warnPanel); - } - - var warningsExpander = new Expander - { - IsExpanded = true, - Header = new TextBlock - { - Text = "Warnings", - FontWeight = FontWeight.SemiBold, - FontSize = 11, - Foreground = SectionHeaderBrush - }, - Content = warningsPanel, - Margin = new Thickness(0, 2, 0, 0), - Padding = new Thickness(0), - Foreground = SectionHeaderBrush, - Background = new SolidColorBrush(Color.FromArgb(0x18, 0x4F, 0xA3, 0xFF)), - BorderBrush = PropSeparatorBrush, - BorderThickness = new Thickness(0, 0, 0, 1), - HorizontalAlignment = HorizontalAlignment.Stretch, - HorizontalContentAlignment = HorizontalAlignment.Stretch - }; - PropertiesContent.Children.Add(warningsExpander); - } - - // Show the panel - _propertiesColumn.Width = new GridLength(320); - _splitterColumn.Width = new GridLength(5); - PropertiesSplitter.IsVisible = true; - PropertiesPanel.IsVisible = true; - } - - private void AddPropertySection(string title) - { - var labelCol = new ColumnDefinition { Width = new GridLength(_propertyLabelWidth) }; - _sectionLabelColumns.Add(labelCol); - - // Sync column widths across sections when user drags the GridSplitter - labelCol.PropertyChanged += (_, args) => - { - if (args.Property.Name != "Width" || _isSyncingColumnWidth) return; - _isSyncingColumnWidth = true; - _propertyLabelWidth = labelCol.Width.Value; - foreach (var col in _sectionLabelColumns) - { - if (col != labelCol) - col.Width = labelCol.Width; - } - _isSyncingColumnWidth = false; - }; - - var sectionGrid = new Grid - { - Margin = new Thickness(6, 0, 6, 0) - }; - sectionGrid.ColumnDefinitions.Add(labelCol); - sectionGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(4) }); - sectionGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); - - _currentSectionGrid = sectionGrid; - _currentSectionRowIndex = 0; - - var expander = new Expander - { - IsExpanded = true, - Header = new TextBlock - { - Text = title, - FontWeight = FontWeight.SemiBold, - FontSize = 11, - Foreground = SectionHeaderBrush - }, - Content = sectionGrid, - Margin = new Thickness(0, 2, 0, 0), - Padding = new Thickness(0), - Foreground = SectionHeaderBrush, - Background = new SolidColorBrush(Color.FromArgb(0x18, 0x4F, 0xA3, 0xFF)), - BorderBrush = PropSeparatorBrush, - BorderThickness = new Thickness(0, 0, 0, 1), - HorizontalAlignment = HorizontalAlignment.Stretch, - HorizontalContentAlignment = HorizontalAlignment.Stretch - }; - PropertiesContent.Children.Add(expander); - } - - private void AddPropertyRow(string label, string value, bool isCode = false, bool indent = false) - { - if (_currentSectionGrid == null) return; - - var row = _currentSectionRowIndex++; - _currentSectionGrid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); - - var labelBlock = new TextBlock - { - Text = label, - FontSize = indent ? 10 : 11, - Foreground = TooltipFgBrush, - VerticalAlignment = VerticalAlignment.Top, - TextWrapping = TextWrapping.Wrap, - Margin = new Thickness(indent ? 16 : 4, 2, 0, 2) - }; - Grid.SetColumn(labelBlock, 0); - Grid.SetRow(labelBlock, row); - _currentSectionGrid.Children.Add(labelBlock); - - // GridSplitter in column 1 (only in first row per section) - if (row == 0) - { - var splitter = new GridSplitter - { - Width = 4, - Background = Brushes.Transparent, - Foreground = Brushes.Transparent, - BorderThickness = new Thickness(0), - Cursor = new Avalonia.Input.Cursor(Avalonia.Input.StandardCursorType.SizeWestEast) - }; - Grid.SetColumn(splitter, 1); - Grid.SetRow(splitter, 0); - Grid.SetRowSpan(splitter, 100); // span all rows - _currentSectionGrid.Children.Add(splitter); - } - - var valueBox = new TextBox - { - Text = value, - FontSize = indent ? 10 : 11, - Foreground = TooltipFgBrush, - TextWrapping = TextWrapping.Wrap, - IsReadOnly = true, - BorderThickness = new Thickness(0), - Background = Brushes.Transparent, - Padding = new Thickness(0), - Margin = new Thickness(0, 2, 4, 2), - VerticalAlignment = VerticalAlignment.Top - }; - if (isCode) valueBox.FontFamily = new FontFamily("Consolas"); - Grid.SetColumn(valueBox, 2); - Grid.SetRow(valueBox, row); - _currentSectionGrid.Children.Add(valueBox); - } - - private void CloseProperties_Click(object? sender, RoutedEventArgs e) - { - ClosePropertiesPanel(); - } - - private void ClosePropertiesPanel() - { - PropertiesPanel.IsVisible = false; - PropertiesSplitter.IsVisible = false; - _propertiesColumn.Width = new GridLength(0); - _splitterColumn.Width = new GridLength(0); - - // Deselect node - if (_selectedNodeBorder != null) - { - _selectedNodeBorder.BorderBrush = _selectedNodeOriginalBorder; - _selectedNodeBorder.BorderThickness = _selectedNodeOriginalThickness; - _selectedNodeBorder = null; - } - } - - #endregion - - #region Tooltips - - private object BuildNodeTooltipContent(PlanNode node, List? allWarnings = null) - { - var tipBorder = new Border - { - Background = TooltipBgBrush, - BorderBrush = TooltipBorderBrush, - BorderThickness = new Thickness(1), - Padding = new Thickness(12), - MaxWidth = 500 - }; - - var stack = new StackPanel(); - - // Header - var headerText = node.PhysicalOp; - if (node.LogicalOp != node.PhysicalOp && !string.IsNullOrEmpty(node.LogicalOp) - && !node.PhysicalOp.Contains(node.LogicalOp, StringComparison.OrdinalIgnoreCase)) - headerText += $" ({node.LogicalOp})"; - stack.Children.Add(new TextBlock - { - Text = headerText, - FontWeight = FontWeight.Bold, - FontSize = 13, - Foreground = TooltipFgBrush, - Margin = new Thickness(0, 0, 0, 8) - }); - - // Cost - AddTooltipSection(stack, "Costs"); - AddTooltipRow(stack, "Cost", $"{node.CostPercent}% of statement ({node.EstimatedOperatorCost:F6})"); - AddTooltipRow(stack, "Subtree Cost", $"{node.EstimatedTotalSubtreeCost:F6}"); - - // Rows - AddTooltipSection(stack, "Rows"); - AddTooltipRow(stack, "Estimated Rows", $"{node.EstimateRows:N1}"); - if (node.HasActualStats) - { - AddTooltipRow(stack, "Actual Rows", $"{node.ActualRows:N0}"); - if (node.ActualRowsRead > 0) - AddTooltipRow(stack, "Actual Rows Read", $"{node.ActualRowsRead:N0}"); - AddTooltipRow(stack, "Actual Executions", $"{node.ActualExecutions:N0}"); - } - - // Rebinds/Rewinds (spools and other operators with rebind/rewind data) - if (node.EstimateRebinds > 0 || node.EstimateRewinds > 0 - || node.ActualRebinds > 0 || node.ActualRewinds > 0) - { - AddTooltipSection(stack, "Rebinds / Rewinds"); - // Always show both estimated values when section is visible - AddTooltipRow(stack, "Est. Rebinds", $"{node.EstimateRebinds:N1}"); - AddTooltipRow(stack, "Est. Rewinds", $"{node.EstimateRewinds:N1}"); - if (node.ActualRebinds > 0) AddTooltipRow(stack, "Actual Rebinds", $"{node.ActualRebinds:N0}"); - if (node.ActualRewinds > 0) AddTooltipRow(stack, "Actual Rewinds", $"{node.ActualRewinds:N0}"); - } - - // I/O and CPU estimates - if (node.EstimateIO > 0 || node.EstimateCPU > 0 || node.EstimatedRowSize > 0) - { - AddTooltipSection(stack, "Estimates"); - if (node.EstimateIO > 0) AddTooltipRow(stack, "I/O Cost", $"{node.EstimateIO:F6}"); - if (node.EstimateCPU > 0) AddTooltipRow(stack, "CPU Cost", $"{node.EstimateCPU:F6}"); - if (node.EstimatedRowSize > 0) AddTooltipRow(stack, "Avg Row Size", $"{node.EstimatedRowSize} B"); - } - - // Actual I/O - if (node.HasActualStats && (node.ActualLogicalReads > 0 || node.ActualPhysicalReads > 0)) - { - AddTooltipSection(stack, "Actual I/O"); - AddTooltipRow(stack, "Logical Reads", $"{node.ActualLogicalReads:N0}"); - if (node.ActualPhysicalReads > 0) - AddTooltipRow(stack, "Physical Reads", $"{node.ActualPhysicalReads:N0}"); - if (node.ActualScans > 0) - AddTooltipRow(stack, "Scans", $"{node.ActualScans:N0}"); - if (node.ActualReadAheads > 0) - AddTooltipRow(stack, "Read-Aheads", $"{node.ActualReadAheads:N0}"); - } - - // Actual timing - if (node.HasActualStats && (node.ActualElapsedMs > 0 || node.ActualCPUMs > 0)) - { - AddTooltipSection(stack, "Timing"); - if (node.ActualElapsedMs > 0) - AddTooltipRow(stack, "Elapsed Time", $"{node.ActualElapsedMs:N0} ms"); - if (node.ActualCPUMs > 0) - AddTooltipRow(stack, "CPU Time", $"{node.ActualCPUMs:N0} ms"); - } - - // Parallelism - if (node.Parallel || !string.IsNullOrEmpty(node.ExecutionMode) || !string.IsNullOrEmpty(node.PartitioningType)) - { - AddTooltipSection(stack, "Parallelism"); - if (node.Parallel) AddTooltipRow(stack, "Parallel", "Yes"); - if (!string.IsNullOrEmpty(node.ExecutionMode)) - AddTooltipRow(stack, "Execution Mode", node.ExecutionMode); - if (!string.IsNullOrEmpty(node.ActualExecutionMode) && node.ActualExecutionMode != node.ExecutionMode) - AddTooltipRow(stack, "Actual Exec Mode", node.ActualExecutionMode); - if (!string.IsNullOrEmpty(node.PartitioningType)) - AddTooltipRow(stack, "Partitioning", node.PartitioningType); - } - - // Object - if (!string.IsNullOrEmpty(node.FullObjectName)) - { - AddTooltipSection(stack, "Object"); - AddTooltipRow(stack, "Name", node.FullObjectName, isCode: true); - if (node.Ordered) AddTooltipRow(stack, "Ordered", "True"); - if (!string.IsNullOrEmpty(node.ScanDirection)) - AddTooltipRow(stack, "Scan Direction", node.ScanDirection); - } - else if (!string.IsNullOrEmpty(node.ObjectName)) - { - AddTooltipSection(stack, "Object"); - AddTooltipRow(stack, "Name", node.ObjectName, isCode: true); - if (node.Ordered) AddTooltipRow(stack, "Ordered", "True"); - if (!string.IsNullOrEmpty(node.ScanDirection)) - AddTooltipRow(stack, "Scan Direction", node.ScanDirection); - } - - // NC index maintenance count - if (node.NonClusteredIndexCount > 0) - AddTooltipRow(stack, "NC Indexes Maintained", string.Join(", ", node.NonClusteredIndexNames)); - - // Operator details (key items only in tooltip) - var hasTooltipDetails = !string.IsNullOrEmpty(node.OrderBy) - || !string.IsNullOrEmpty(node.TopExpression) - || !string.IsNullOrEmpty(node.GroupBy) - || !string.IsNullOrEmpty(node.OuterReferences); - if (hasTooltipDetails) - { - AddTooltipSection(stack, "Details"); - if (!string.IsNullOrEmpty(node.OrderBy)) - AddTooltipRow(stack, "Order By", node.OrderBy, isCode: true); - if (!string.IsNullOrEmpty(node.TopExpression)) - AddTooltipRow(stack, "Top", node.IsPercent ? $"{node.TopExpression} PERCENT" : node.TopExpression); - if (!string.IsNullOrEmpty(node.GroupBy)) - AddTooltipRow(stack, "Group By", node.GroupBy, isCode: true); - if (!string.IsNullOrEmpty(node.OuterReferences)) - AddTooltipRow(stack, "Outer References", node.OuterReferences, isCode: true); - } - - // Predicates - if (!string.IsNullOrEmpty(node.SeekPredicates) || !string.IsNullOrEmpty(node.Predicate)) - { - AddTooltipSection(stack, "Predicates"); - if (!string.IsNullOrEmpty(node.SeekPredicates)) - AddTooltipRow(stack, "Seek", node.SeekPredicates, isCode: true); - if (!string.IsNullOrEmpty(node.Predicate)) - AddTooltipRow(stack, "Residual", node.Predicate, isCode: true); - } - - // Output columns - if (!string.IsNullOrEmpty(node.OutputColumns)) - { - AddTooltipSection(stack, "Output"); - AddTooltipRow(stack, "Columns", node.OutputColumns, isCode: true); - } - - // Warnings — use allWarnings (all nodes) for root, node.Warnings for others - var warnings = allWarnings ?? (node.HasWarnings ? node.Warnings : null); - if (warnings != null && warnings.Count > 0) - { - stack.Children.Add(new Separator { Margin = new Thickness(0, 6, 0, 6) }); - - if (allWarnings != null) - { - // Root node: show distinct warning type names only, sorted by max benefit - var distinct = warnings - .GroupBy(w => w.WarningType) - .Select(g => (Type: g.Key, MaxSeverity: g.Max(w => w.Severity), Count: g.Count(), - MaxBenefit: g.Max(w => w.MaxBenefitPercent ?? -1))) - .OrderByDescending(g => g.MaxBenefit) - .ThenByDescending(g => g.MaxSeverity) - .ThenBy(g => g.Type); - - foreach (var (type, severity, count, maxBenefit) in distinct) - { - var warnColor = severity == PlanWarningSeverity.Critical ? "#E57373" - : severity == PlanWarningSeverity.Warning ? "#FFB347" : "#6BB5FF"; - var benefitSuffix = maxBenefit >= 0 ? $" \u2014 up to {maxBenefit:N0}%" : ""; - var label = count > 1 ? $"\u26A0 {type} ({count}){benefitSuffix}" : $"\u26A0 {type}{benefitSuffix}"; - stack.Children.Add(new TextBlock - { - Text = label, - Foreground = new SolidColorBrush(Color.Parse(warnColor)), - FontSize = 11, - Margin = new Thickness(0, 2, 0, 0) - }); - } - } - else - { - // Individual node: show full warning messages - foreach (var w in warnings) - { - var warnColor = w.Severity == PlanWarningSeverity.Critical ? "#E57373" - : w.Severity == PlanWarningSeverity.Warning ? "#FFB347" : "#6BB5FF"; - stack.Children.Add(new TextBlock - { - Text = $"\u26A0 {w.WarningType}: {w.Message}", - Foreground = new SolidColorBrush(Color.Parse(warnColor)), - FontSize = 11, - TextWrapping = TextWrapping.Wrap, - Margin = new Thickness(0, 2, 0, 0) - }); - } - } - } - - // Footer hint - stack.Children.Add(new TextBlock - { - Text = "Click to view full properties", - FontSize = 10, - FontStyle = FontStyle.Italic, - Foreground = TooltipFgBrush, - Margin = new Thickness(0, 8, 0, 0) - }); - - tipBorder.Child = stack; - return tipBorder; - } - - private static void AddTooltipSection(StackPanel parent, string title) - { - parent.Children.Add(new TextBlock - { - Text = title, - FontSize = 10, - FontWeight = FontWeight.SemiBold, - Foreground = SectionHeaderBrush, - Margin = new Thickness(0, 6, 0, 2) - }); - } - - private static void AddTooltipRow(StackPanel parent, string label, string value, bool isCode = false) - { - var row = new Grid - { - ColumnDefinitions = new ColumnDefinitions("Auto,*"), - Margin = new Thickness(0, 1, 0, 1) - }; - var labelBlock = new TextBlock - { - Text = $"{label}: ", - Foreground = TooltipFgBrush, - FontSize = 11, - MinWidth = 120, - VerticalAlignment = VerticalAlignment.Top - }; - Grid.SetColumn(labelBlock, 0); - row.Children.Add(labelBlock); - - var valueBlock = new TextBlock - { - Text = value, - FontSize = 11, - Foreground = TooltipFgBrush, - TextWrapping = TextWrapping.Wrap - }; - if (isCode) valueBlock.FontFamily = new FontFamily("Consolas"); - Grid.SetColumn(valueBlock, 1); - row.Children.Add(valueBlock); - parent.Children.Add(row); - } - - #endregion - - #region Banners - - 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 static void CollectWarnings(PlanNode node, List warnings) - { - warnings.AddRange(node.Warnings); - foreach (var child in node.Children) - CollectWarnings(child, warnings); - } - - /// - /// 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(); - - 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" - }; - } - - #endregion - - #region Zoom - - private void ZoomIn_Click(object? sender, RoutedEventArgs e) => SetZoom(_zoomLevel + ZoomStep); - private void ZoomOut_Click(object? sender, RoutedEventArgs e) => SetZoom(_zoomLevel - ZoomStep); - - private void ZoomFit_Click(object? sender, RoutedEventArgs e) - { - if (PlanCanvas.Width <= 0 || PlanCanvas.Height <= 0) return; - - var viewWidth = PlanScrollViewer.Bounds.Width; - var viewHeight = PlanScrollViewer.Bounds.Height; - if (viewWidth <= 0 || viewHeight <= 0) return; - - var fitZoom = Math.Min(viewWidth / PlanCanvas.Width, viewHeight / PlanCanvas.Height); - SetZoom(Math.Min(fitZoom, 1.0)); - PlanScrollViewer.Offset = new Avalonia.Vector(0, 0); - } - - private void SetZoom(double level) - { - _zoomLevel = Math.Max(MinZoom, Math.Min(MaxZoom, level)); - _zoomTransform.ScaleX = _zoomLevel; - _zoomTransform.ScaleY = _zoomLevel; - ZoomLevelText.Text = $"{(int)(_zoomLevel * 100)}%"; - UpdateMinimapViewportBox(); - } - - /// - /// Sets the zoom level and adjusts the scroll offset so that the content point - /// under stays fixed in the viewport. - /// - private void SetZoomAtPoint(double level, Point viewportAnchor) - { - var newZoom = Math.Max(MinZoom, Math.Min(MaxZoom, level)); - if (Math.Abs(newZoom - _zoomLevel) < 0.001) - return; - - // Content point under the anchor at the current zoom level - var contentX = (PlanScrollViewer.Offset.X + viewportAnchor.X) / _zoomLevel; - var contentY = (PlanScrollViewer.Offset.Y + viewportAnchor.Y) / _zoomLevel; - - // Apply the new zoom - SetZoom(newZoom); - - // Adjust offset so the same content point stays under the anchor - var newOffsetX = Math.Max(0, contentX * _zoomLevel - viewportAnchor.X); - var newOffsetY = Math.Max(0, contentY * _zoomLevel - viewportAnchor.Y); - - Avalonia.Threading.Dispatcher.UIThread.Post(() => - { - PlanScrollViewer.Offset = new Vector(newOffsetX, newOffsetY); - UpdateMinimapViewportBox(); - }); - } - - private void PlanScrollViewer_PointerWheelChanged(object? sender, PointerWheelEventArgs e) - { - if (e.KeyModifiers.HasFlag(KeyModifiers.Control)) - { - e.Handled = true; - var newLevel = _zoomLevel + (e.Delta.Y > 0 ? ZoomStep : -ZoomStep); - SetZoomAtPoint(newLevel, e.GetPosition(PlanScrollViewer)); - } - } - - private void PlanScrollViewer_PointerPressed(object? sender, PointerPressedEventArgs e) - { - // Don't intercept scrollbar interactions - if (IsScrollBarAtPoint(e)) - return; - - var point = e.GetCurrentPoint(PlanScrollViewer); - var isMiddle = point.Properties.IsMiddleButtonPressed; - var isLeft = point.Properties.IsLeftButtonPressed; - - // Middle mouse always pans; left-click pans only on empty canvas (not on nodes) - if (isMiddle || (isLeft && !IsNodeAtPoint(e))) - { - _isPanning = true; - _panStart = point.Position; - _panStartOffsetX = PlanScrollViewer.Offset.X; - _panStartOffsetY = PlanScrollViewer.Offset.Y; - PlanScrollViewer.Cursor = new Cursor(StandardCursorType.SizeAll); - e.Pointer.Capture(PlanScrollViewer); - e.Handled = true; - } - } - - private void PlanScrollViewer_PointerMoved(object? sender, PointerEventArgs e) - { - if (!_isPanning) return; - - var current = e.GetPosition(PlanScrollViewer); - var dx = current.X - _panStart.X; - var dy = current.Y - _panStart.Y; - - var newX = Math.Max(0, _panStartOffsetX - dx); - var newY = Math.Max(0, _panStartOffsetY - dy); - - // Defer offset change so the ScrollViewer doesn't overwrite it during layout - Avalonia.Threading.Dispatcher.UIThread.Post(() => - { - PlanScrollViewer.Offset = new Vector(newX, newY); - UpdateMinimapViewportBox(); - }); - - e.Handled = true; - } - - private void PlanScrollViewer_PointerReleased(object? sender, PointerReleasedEventArgs e) - { - if (!_isPanning) return; - _isPanning = false; - PlanScrollViewer.Cursor = Cursor.Default; - e.Pointer.Capture(null); - e.Handled = true; - } - - /// Check if the pointer event originated from a node Border. - private bool IsNodeAtPoint(PointerPressedEventArgs e) - { - // Walk up the visual tree from the source to see if we hit a node border - var source = e.Source as Control; - while (source != null && source != PlanCanvas) - { - if (source is Border b && _nodeBorderMap.ContainsKey(b)) - return true; - source = source.Parent as Control; - } - return false; - } - - /// Check if the pointer event originated from a ScrollBar. - private bool IsScrollBarAtPoint(PointerPressedEventArgs e) - { - var source = e.Source as Control; - while (source != null && source != PlanScrollViewer) - { - if (source is ScrollBar) - return true; - source = source.Parent as Control; - } - return false; - } - - #endregion - - #region Save & Statement Selection - - private async void SavePlan_Click(object? sender, RoutedEventArgs e) - { - if (_currentPlan == null || string.IsNullOrEmpty(_currentPlan.RawXml)) return; - - var topLevel = TopLevel.GetTopLevel(this); - if (topLevel == null) return; - - var file = await topLevel.StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions - { - Title = "Save Plan", - DefaultExtension = "sqlplan", - SuggestedFileName = $"plan_{DateTime.Now:yyyyMMdd_HHmmss}.sqlplan", - FileTypeChoices = new[] - { - new FilePickerFileType("SQL Plan Files") { Patterns = new[] { "*.sqlplan" } }, - new FilePickerFileType("XML Files") { Patterns = new[] { "*.xml" } }, - new FilePickerFileType("All Files") { Patterns = new[] { "*.*" } } - } - }); - - if (file != null) - { - try - { - await using var stream = await file.OpenWriteAsync(); - await using var writer = new StreamWriter(stream); - await writer.WriteAsync(_currentPlan.RawXml); - } - catch (Exception ex) - { - System.Diagnostics.Debug.WriteLine($"SavePlan failed: {ex.Message}"); - CostText.Text = $"Save failed: {(ex.Message.Length > 60 ? ex.Message[..60] + "..." : ex.Message)}"; - } - } - } - - #endregion - - #region Statements Panel - - private void PopulateStatementsGrid(List statements) - { - StatementsHeader.Text = $"Statements ({statements.Count})"; - - var hasActualTimes = statements.Any(s => s.QueryTimeStats != null && - (s.QueryTimeStats.CpuTimeMs > 0 || s.QueryTimeStats.ElapsedTimeMs > 0)); - var hasUdf = statements.Any(s => s.QueryUdfElapsedTimeMs > 0); - - // Build columns - StatementsGrid.Columns.Clear(); - - StatementsGrid.Columns.Add(new DataGridTextColumn - { - Header = "#", - Binding = new Avalonia.Data.Binding("Index"), - Width = new DataGridLength(40), - IsReadOnly = true - }); - - var queryTemplate = new FuncDataTemplate((row, _) => - { - if (row == null) return new TextBlock(); - var tb = new TextBlock - { - Text = row.QueryText, - TextWrapping = TextWrapping.Wrap, - MaxHeight = 80, - FontSize = 11, - Margin = new Thickness(4, 2) - }; - ToolTip.SetTip(tb, new TextBlock - { - Text = row.FullQueryText, - TextWrapping = TextWrapping.Wrap, - MaxWidth = 600, - FontFamily = new FontFamily("Consolas"), - FontSize = 11 - }); - return tb; - }, supportsRecycling: false); - - StatementsGrid.Columns.Add(new DataGridTemplateColumn - { - Header = "Query", - CellTemplate = queryTemplate, - Width = new DataGridLength(250), - IsReadOnly = true - }); - - if (hasActualTimes) - { - StatementsGrid.Columns.Add(new DataGridTextColumn - { - Header = "CPU", - Binding = new Avalonia.Data.Binding("CpuDisplay"), - Width = new DataGridLength(70), - IsReadOnly = true, - CustomSortComparer = new LongComparer(r => r.CpuMs) - }); - StatementsGrid.Columns.Add(new DataGridTextColumn - { - Header = "Elapsed", - Binding = new Avalonia.Data.Binding("ElapsedDisplay"), - Width = new DataGridLength(70), - IsReadOnly = true, - CustomSortComparer = new LongComparer(r => r.ElapsedMs) - }); - } - - if (hasUdf) - { - StatementsGrid.Columns.Add(new DataGridTextColumn - { - Header = "UDF", - Binding = new Avalonia.Data.Binding("UdfDisplay"), - Width = new DataGridLength(70), - IsReadOnly = true, - CustomSortComparer = new LongComparer(r => r.UdfMs) - }); - } - - if (!hasActualTimes) - { - StatementsGrid.Columns.Add(new DataGridTextColumn - { - Header = "Est. Cost", - Binding = new Avalonia.Data.Binding("CostDisplay"), - Width = new DataGridLength(80), - IsReadOnly = true, - CustomSortComparer = new DoubleComparer(r => r.EstCost) - }); - } - - StatementsGrid.Columns.Add(new DataGridTextColumn - { - Header = "Critical", - Binding = new Avalonia.Data.Binding("Critical"), - Width = new DataGridLength(60), - IsReadOnly = true - }); - - StatementsGrid.Columns.Add(new DataGridTextColumn - { - Header = "Warnings", - Binding = new Avalonia.Data.Binding("Warnings"), - Width = new DataGridLength(70), - IsReadOnly = true - }); - - // Build rows - var rows = new List(); - for (int i = 0; i < statements.Count; i++) - { - var stmt = statements[i]; - var allWarnings = stmt.PlanWarnings.ToList(); - if (stmt.RootNode != null) - CollectNodeWarnings(stmt.RootNode, allWarnings); - - var fullText = stmt.StatementText; - if (string.IsNullOrWhiteSpace(fullText)) - fullText = $"Statement {i + 1}"; - var displayText = fullText.Length > 120 ? fullText[..120] + "..." : fullText; - - rows.Add(new StatementRow - { - Index = i + 1, - QueryText = displayText, - FullQueryText = fullText, - CpuMs = stmt.QueryTimeStats?.CpuTimeMs ?? 0, - ElapsedMs = stmt.QueryTimeStats?.ElapsedTimeMs ?? 0, - UdfMs = stmt.QueryUdfElapsedTimeMs, - EstCost = stmt.StatementSubTreeCost, - Critical = allWarnings.Count(w => w.Severity == PlanWarningSeverity.Critical), - Warnings = allWarnings.Count(w => w.Severity == PlanWarningSeverity.Warning), - Statement = stmt - }); - } - - StatementsGrid.ItemsSource = rows; - } - - private void StatementsGrid_SelectionChanged(object? sender, SelectionChangedEventArgs e) - { - if (StatementsGrid.SelectedItem is StatementRow row) - RenderStatement(row.Statement); - } - - private async void CopyStatementText_Click(object? sender, RoutedEventArgs e) - { - if (StatementsGrid.SelectedItem is not StatementRow row) return; - var text = row.Statement.StatementText; - if (string.IsNullOrEmpty(text)) return; - - var topLevel = TopLevel.GetTopLevel(this); - if (topLevel?.Clipboard != null) - await topLevel.Clipboard.SetTextAsync(text); - } - - private void OpenInEditor_Click(object? sender, RoutedEventArgs e) - { - if (StatementsGrid.SelectedItem is not StatementRow row) return; - var text = row.Statement.StatementText; - if (string.IsNullOrEmpty(text)) return; - - OpenInEditorRequested?.Invoke(this, text); - } - - private static void CollectNodeWarnings(PlanNode node, List warnings) - { - warnings.AddRange(node.Warnings); - foreach (var child in node.Children) - CollectNodeWarnings(child, warnings); - } - - private void ToggleStatements_Click(object? sender, RoutedEventArgs e) - { - if (StatementsPanel.IsVisible) - CloseStatementsPanel(); - else - ShowStatementsPanel(); - } - - private void CloseStatements_Click(object? sender, RoutedEventArgs e) - { - CloseStatementsPanel(); - } - - private void ShowStatementsPanel() - { - _statementsColumn.Width = new GridLength(450); - _statementsSplitterColumn.Width = new GridLength(5); - StatementsSplitter.IsVisible = true; - StatementsPanel.IsVisible = true; - StatementsButton.IsVisible = true; - StatementsButtonSeparator.IsVisible = true; - } - - private void CloseStatementsPanel() - { - StatementsPanel.IsVisible = false; - StatementsSplitter.IsVisible = false; - _statementsColumn.Width = new GridLength(0); - _statementsSplitterColumn.Width = new GridLength(0); - } - - #endregion - - #region Minimap - - private void MinimapToggle_Click(object? sender, RoutedEventArgs e) - { - if (MinimapPanel.IsVisible) - CloseMinimapPanel(); - else - OpenMinimapPanel(); - } - - private void MinimapClose_Click(object? sender, RoutedEventArgs e) - { - CloseMinimapPanel(); - } - - private void OpenMinimapPanel() - { - MinimapPanel.Width = _minimapWidth; - MinimapPanel.Height = _minimapHeight; - MinimapPanel.IsVisible = true; - RenderMinimap(); - } - - private void CloseMinimapPanel() - { - MinimapPanel.IsVisible = false; - _minimapDragging = false; - _minimapResizing = false; - } - - private void RenderMinimap() - { - MinimapCanvas.Children.Clear(); - _minimapNodeMap.Clear(); - _minimapViewportBox = null; - _minimapSelectedNode = null; - - // Guard: don't render if the panel was closed between a deferred post and execution - if (!MinimapPanel.IsVisible) return; - - if (_currentStatement?.RootNode == null || PlanCanvas.Width <= 0 || PlanCanvas.Height <= 0) - return; - - var canvasW = MinimapCanvas.Bounds.Width; - var canvasH = MinimapCanvas.Bounds.Height; - if (canvasW <= 0 || canvasH <= 0) - { - // Defer until layout is ready - Avalonia.Threading.Dispatcher.UIThread.Post(RenderMinimap, Avalonia.Threading.DispatcherPriority.Loaded); - return; - } - - var scaleX = canvasW / PlanCanvas.Width; - var scaleY = canvasH / PlanCanvas.Height; - var scale = Math.Min(scaleX, scaleY); - - // Cache the non-expensive node border brush for this render cycle - _minimapNodeBorderBrushCache = FindBrushResource("ForegroundBrush") is SolidColorBrush fg - ? new SolidColorBrush(Color.FromArgb(0x80, fg.Color.R, fg.Color.G, fg.Color.B)) - : FindBrushResource("BorderBrush"); - - // Render branch areas with transparent colored backgrounds - RenderMinimapBranches(_currentStatement.RootNode, scale); - // Render edges - var minimapDivergenceLimit = Math.Max(2.0, AppSettingsService.Load().AccuracyRatioDivergenceLimit); - RenderMinimapEdges(_currentStatement.RootNode, scale, minimapDivergenceLimit); - - // Render nodes - RenderMinimapNodes(_currentStatement.RootNode, scale); - - // Render viewport indicator - RenderMinimapViewportBox(scale); + #region Minimap - // Re-apply selection highlight if a node is selected - if (_selectedNode != null) - UpdateMinimapSelection(_selectedNode); - } private static readonly Color[] MinimapBranchColors = { @@ -3639,436 +394,13 @@ private void RenderMinimap() Color.FromArgb(0x30, 0xFF, 0x7B, 0xA5), // pink }; - private void RenderMinimapBranches(PlanNode root, double scale) - { - - for (int i = 0; i < root.Children.Count; i++) - { - var child = root.Children[i]; - var color = MinimapBranchColors[i % MinimapBranchColors.Length]; - - // Collect bounds of all nodes in this subtree - double minX = double.MaxValue, minY = double.MaxValue; - double maxX = double.MinValue, maxY = double.MinValue; - CollectSubtreeBounds(child, ref minX, ref minY, ref maxX, ref maxY); - - var rect = new Avalonia.Controls.Shapes.Rectangle - { - Width = (maxX - minX + PlanLayoutEngine.NodeWidth) * scale + 4, - Height = (maxY - minY + PlanLayoutEngine.GetNodeHeight(child)) * scale + 4, - Fill = new SolidColorBrush(color), - RadiusX = 2, - RadiusY = 2 - }; - Canvas.SetLeft(rect, minX * scale - 2); - Canvas.SetTop(rect, minY * scale - 2); - MinimapCanvas.Children.Add(rect); - } - } - - private static void CollectSubtreeBounds(PlanNode node, ref double minX, ref double minY, ref double maxX, ref double maxY) - { - if (node.X < minX) minX = node.X; - if (node.Y < minY) minY = node.Y; - if (node.X > maxX) maxX = node.X; - var bottom = node.Y + PlanLayoutEngine.GetNodeHeight(node); - if (bottom > maxY) maxY = bottom; - - foreach (var child in node.Children) - CollectSubtreeBounds(child, ref minX, ref minY, ref maxX, ref maxY); - } - - private void RenderMinimapEdges(PlanNode node, double scale, double divergenceLimit) - { - foreach (var child in node.Children) - { - var parentRight = (node.X + PlanLayoutEngine.NodeWidth) * scale; - var parentCenterY = (node.Y + PlanLayoutEngine.GetNodeHeight(node) / 2) * scale; - var childLeft = child.X * scale; - var childCenterY = (child.Y + PlanLayoutEngine.GetNodeHeight(child) / 2) * scale; - var midX = (parentRight + childLeft) / 2; - - // Proportional thickness matching the plan viewer (logarithmic, scaled down) - var rows = child.HasActualStats ? child.ActualRows : child.EstimateRows; - var fullThickness = Math.Max(2, Math.Min(Math.Floor(Math.Log(Math.Max(1, rows))), 12)); - var thickness = Math.Max(0.5, fullThickness * scale); - - var geometry = new PathGeometry(); - var figure = new PathFigure { StartPoint = new Point(parentRight, parentCenterY), IsClosed = false }; - figure.Segments!.Add(new LineSegment { Point = new Point(midX, parentCenterY) }); - figure.Segments.Add(new LineSegment { Point = new Point(midX, childCenterY) }); - figure.Segments.Add(new LineSegment { Point = new Point(childLeft, childCenterY) }); - geometry.Figures!.Add(figure); - - var linkBrush = GetLinkColorBrush(child, divergenceLimit); - - var path = new AvaloniaPath - { - Data = geometry, - Stroke = linkBrush, - StrokeThickness = thickness, - StrokeJoin = PenLineJoin.Round - }; - MinimapCanvas.Children.Add(path); - - RenderMinimapEdges(child, scale, divergenceLimit); - } - } // Cached per render cycle in RenderMinimap() to avoid per-node brush creation private IBrush _minimapNodeBorderBrushCache = Brushes.Gray; - private void RenderMinimapNodes(PlanNode node, double scale) - { - var w = PlanLayoutEngine.NodeWidth * scale; - var h = PlanLayoutEngine.GetNodeHeight(node) * scale; - // Use theme background colors with transparency - var bgBrush = node.IsExpensive - ? MinimapExpensiveNodeBgBrush - : FindBrushResource("BackgroundLightBrush"); - var borderBrush = node.IsExpensive ? OrangeRedBrush : _minimapNodeBorderBrushCache; - - var border = new Border - { - Width = Math.Max(4, w), - Height = Math.Max(4, h), - Background = bgBrush, - BorderBrush = borderBrush, - BorderThickness = new Thickness(0.5), - CornerRadius = new CornerRadius(1) - }; - - // Show a small icon inside the node if space allows - var iconBitmap = IconHelper.LoadIcon(node.IconName); - if (iconBitmap != null) - { - var iconSize = Math.Min(Math.Min(w * 0.7, h * 0.7), 16); - if (iconSize >= 6) - { - border.Child = new Image - { - Source = iconBitmap, - Width = iconSize, - Height = iconSize, - HorizontalAlignment = HorizontalAlignment.Center, - VerticalAlignment = VerticalAlignment.Center - }; - } - } - - Canvas.SetLeft(border, node.X * scale); - Canvas.SetTop(border, node.Y * scale); - MinimapCanvas.Children.Add(border); - - _minimapNodeMap[border] = node; - - foreach (var child in node.Children) - RenderMinimapNodes(child, scale); - } - - private void RenderMinimapViewportBox(double scale) - { - var viewW = PlanScrollViewer.Bounds.Width; - var viewH = PlanScrollViewer.Bounds.Height; - if (viewW <= 0 || viewH <= 0) return; - - var contentW = PlanCanvas.Width * _zoomLevel; - var contentH = PlanCanvas.Height * _zoomLevel; - - var boxW = Math.Min(viewW / contentW, 1.0) * PlanCanvas.Width * scale; - var boxH = Math.Min(viewH / contentH, 1.0) * PlanCanvas.Height * scale; - var boxX = (PlanScrollViewer.Offset.X / _zoomLevel) * scale; - var boxY = (PlanScrollViewer.Offset.Y / _zoomLevel) * scale; - - var accentColor = FindBrushResource("AccentBrush") is SolidColorBrush ab - ? ab.Color - : Color.FromRgb(0x2E, 0xAE, 0xF1); - var themeBrush = new SolidColorBrush(Color.FromArgb(0x40, accentColor.R, accentColor.G, accentColor.B)); - var borderBrush = new SolidColorBrush(Color.FromArgb(0xB0, accentColor.R, accentColor.G, accentColor.B)); - - _minimapViewportBox = new Border - { - Width = Math.Max(4, boxW), - Height = Math.Max(4, boxH), - Background = themeBrush, - BorderBrush = borderBrush, - BorderThickness = new Thickness(1.5), - CornerRadius = new CornerRadius(1), - Cursor = new Cursor(StandardCursorType.SizeAll) - }; - Canvas.SetLeft(_minimapViewportBox, boxX); - Canvas.SetTop(_minimapViewportBox, boxY); - MinimapCanvas.Children.Add(_minimapViewportBox); - } - - private void UpdateMinimapViewportBox() - { - if (!MinimapPanel.IsVisible || _minimapViewportBox == null || _currentStatement?.RootNode == null) - return; - if (PlanCanvas.Width <= 0 || PlanCanvas.Height <= 0) return; - - var canvasW = MinimapCanvas.Bounds.Width; - var canvasH = MinimapCanvas.Bounds.Height; - if (canvasW <= 0 || canvasH <= 0) return; - - var scaleX = canvasW / PlanCanvas.Width; - var scaleY = canvasH / PlanCanvas.Height; - var scale = Math.Min(scaleX, scaleY); - - var viewW = PlanScrollViewer.Bounds.Width; - var viewH = PlanScrollViewer.Bounds.Height; - if (viewW <= 0 || viewH <= 0) return; - - var contentW = PlanCanvas.Width * _zoomLevel; - var contentH = PlanCanvas.Height * _zoomLevel; - - _minimapViewportBox.Width = Math.Max(4, Math.Min(viewW / contentW, 1.0) * PlanCanvas.Width * scale); - _minimapViewportBox.Height = Math.Max(4, Math.Min(viewH / contentH, 1.0) * PlanCanvas.Height * scale); - Canvas.SetLeft(_minimapViewportBox, (PlanScrollViewer.Offset.X / _zoomLevel) * scale); - Canvas.SetTop(_minimapViewportBox, (PlanScrollViewer.Offset.Y / _zoomLevel) * scale); - } - - private double GetMinimapScale() - { - if (PlanCanvas.Width <= 0 || PlanCanvas.Height <= 0) return 1; - var canvasW = MinimapCanvas.Bounds.Width; - var canvasH = MinimapCanvas.Bounds.Height; - if (canvasW <= 0 || canvasH <= 0) return 1; - return Math.Min(canvasW / PlanCanvas.Width, canvasH / PlanCanvas.Height); - } - - private void UpdateMinimapSelection(PlanNode node) - { - if (!MinimapPanel.IsVisible) return; - - // Reset previous selection highlight - if (_minimapSelectedNode != null) - { - var prevNode = _minimapNodeMap.GetValueOrDefault(_minimapSelectedNode); - _minimapSelectedNode.BorderBrush = prevNode is { IsExpensive: true } - ? OrangeRedBrush - : _minimapNodeBorderBrushCache; - _minimapSelectedNode.BorderThickness = new Thickness(0.5); - _minimapSelectedNode = null; - } - - // Find and highlight the new node - foreach (var (border, n) in _minimapNodeMap) - { - if (n == node) - { - border.BorderBrush = SelectionBrush; - border.BorderThickness = new Thickness(2); - _minimapSelectedNode = border; - break; - } - } - } - - private void MinimapCanvas_PointerPressed(object? sender, PointerPressedEventArgs e) - { - var point = e.GetCurrentPoint(MinimapCanvas); - if (!point.Properties.IsLeftButtonPressed) return; - - var pos = point.Position; - var scale = GetMinimapScale(); - - // Check if clicking on a node (single click = center, double click = zoom) - if (e.ClickCount == 2) - { - // Double click: find node under pointer and zoom to it - var node = FindMinimapNodeAt(pos); - if (node != null) - { - ZoomToNode(node); - e.Handled = true; - return; - } - } - - if (e.ClickCount == 1) - { - // Check if over a minimap node for single-click centering - var node = FindMinimapNodeAt(pos); - if (node != null) - { - CenterOnNode(node); - e.Handled = true; - return; - } - } - - // Start viewport box drag - _minimapDragging = true; - - // Move viewport center to click position - ScrollPlanViewerToMinimapPoint(pos, scale); - - e.Pointer.Capture(MinimapCanvas); - e.Handled = true; - } - - private void MinimapCanvas_PointerMoved(object? sender, PointerEventArgs e) - { - if (!_minimapDragging) return; - - var pos = e.GetPosition(MinimapCanvas); - var scale = GetMinimapScale(); - ScrollPlanViewerToMinimapPoint(pos, scale); - e.Handled = true; - } - - private void MinimapCanvas_PointerReleased(object? sender, PointerReleasedEventArgs e) - { - if (!_minimapDragging) return; - _minimapDragging = false; - e.Pointer.Capture(null); - e.Handled = true; - } - - private void ScrollPlanViewerToMinimapPoint(Point minimapPoint, double scale) - { - if (scale <= 0) return; - // Convert minimap coords to plan content coords - var contentX = minimapPoint.X / scale; - var contentY = minimapPoint.Y / scale; - - // Center the viewport on this content point - var viewW = PlanScrollViewer.Bounds.Width; - var viewH = PlanScrollViewer.Bounds.Height; - var offsetX = Math.Max(0, contentX * _zoomLevel - viewW / 2); - var offsetY = Math.Max(0, contentY * _zoomLevel - viewH / 2); - - Avalonia.Threading.Dispatcher.UIThread.Post(() => - { - PlanScrollViewer.Offset = new Vector(offsetX, offsetY); - }); - } - - private PlanNode? FindMinimapNodeAt(Point pos) - { - foreach (var (border, node) in _minimapNodeMap) - { - var left = Canvas.GetLeft(border); - var top = Canvas.GetTop(border); - if (pos.X >= left && pos.X <= left + border.Width && - pos.Y >= top && pos.Y <= top + border.Height) - return node; - } - return null; - } - - private void CenterOnNode(PlanNode node) - { - var nodeW = PlanLayoutEngine.NodeWidth; - var nodeH = PlanLayoutEngine.GetNodeHeight(node); - var viewW = PlanScrollViewer.Bounds.Width; - var viewH = PlanScrollViewer.Bounds.Height; - var centerX = (node.X + nodeW / 2) * _zoomLevel - viewW / 2; - var centerY = (node.Y + nodeH / 2) * _zoomLevel - viewH / 2; - centerX = Math.Max(0, centerX); - centerY = Math.Max(0, centerY); - - Avalonia.Threading.Dispatcher.UIThread.Post(() => - { - PlanScrollViewer.Offset = new Vector(centerX, centerY); - }); - } - - private void ZoomToNode(PlanNode node) - { - var viewW = PlanScrollViewer.Bounds.Width; - var viewH = PlanScrollViewer.Bounds.Height; - if (viewW <= 0 || viewH <= 0) return; - - var nodeW = PlanLayoutEngine.NodeWidth; - var nodeH = PlanLayoutEngine.GetNodeHeight(node); - - // Zoom so the node takes about 1/3 of the viewport - var fitZoom = Math.Min(viewW / (nodeW * 3), viewH / (nodeH * 3)); - fitZoom = Math.Max(MinZoom, Math.Min(MaxZoom, fitZoom)); - SetZoom(fitZoom); - - // Center on the node - var centerX = (node.X + nodeW / 2) * _zoomLevel - viewW / 2; - var centerY = (node.Y + nodeH / 2) * _zoomLevel - viewH / 2; - - Avalonia.Threading.Dispatcher.UIThread.Post(() => - { - PlanScrollViewer.Offset = new Vector(Math.Max(0, centerX), Math.Max(0, centerY)); - }); - - // Also select the node in the plan - foreach (var (border, n) in _nodeBorderMap) - { - if (n == node) - { - SelectNode(border, node); - break; - } - } - } - - private void MinimapResizeGrip_PointerPressed(object? sender, PointerPressedEventArgs e) - { - var point = e.GetCurrentPoint(MinimapPanel); - if (!point.Properties.IsLeftButtonPressed) return; - _minimapResizing = true; - _minimapResizeStart = point.Position; - _minimapResizeStartW = MinimapPanel.Width; - _minimapResizeStartH = MinimapPanel.Height; - e.Pointer.Capture((Control)sender!); - e.Handled = true; - } - - private void MinimapResizeGrip_PointerMoved(object? sender, PointerEventArgs e) - { - if (!_minimapResizing) return; - var current = e.GetPosition(MinimapPanel); - var dx = current.X - _minimapResizeStart.X; - var dy = current.Y - _minimapResizeStart.Y; - var newW = Math.Max(MinimapMinSize, Math.Min(MinimapMaxSize, _minimapResizeStartW + dx)); - var newH = Math.Max(MinimapMinSize, Math.Min(MinimapMaxSize, _minimapResizeStartH + dy)); - MinimapPanel.Width = newW; - MinimapPanel.Height = newH; - _minimapWidth = newW; - _minimapHeight = newH; - e.Handled = true; - - // Re-render after resize - Avalonia.Threading.Dispatcher.UIThread.Post(RenderMinimap, Avalonia.Threading.DispatcherPriority.Background); - } - - private void MinimapResizeGrip_PointerReleased(object? sender, PointerReleasedEventArgs e) - { - if (!_minimapResizing) return; - _minimapResizing = false; - e.Pointer.Capture(null); - e.Handled = true; - RenderMinimap(); - } #endregion - #region Helpers - - private IBrush FindBrushResource(string key) - { - if (this.TryFindResource(key, out var resource) && resource is IBrush brush) - return brush; - - // Fallback brushes in case resources are not found - return key switch - { - "BackgroundLightBrush" => new SolidColorBrush(Color.FromRgb(0x23, 0x26, 0x2E)), - "BorderBrush" => new SolidColorBrush(Color.FromRgb(0x3A, 0x3D, 0x45)), - "ForegroundBrush" => new SolidColorBrush(Color.FromRgb(0xE4, 0xE6, 0xEB)), - "ForegroundMutedBrush" => new SolidColorBrush(Color.FromRgb(0xE4, 0xE6, 0xEB)), - _ => Brushes.White - }; - } - - #endregion #region Plan Viewer Connection @@ -4138,334 +470,9 @@ private void PlanDatabase_SelectionChanged(object? sender, SelectionChangedEvent #region Schema Lookup - private static bool IsTempObject(string objectName) - { - // #temp tables, ##global temp, @table variables, internal worktables - return objectName.Contains('#') || objectName.Contains('@') - || objectName.Contains("worktable", StringComparison.OrdinalIgnoreCase) - || objectName.Contains("worksort", StringComparison.OrdinalIgnoreCase); - } - - private static bool IsDataAccessOperator(PlanNode node) - { - var op = node.PhysicalOp; - if (string.IsNullOrEmpty(op)) return false; - - // Modification operators and data access operators reference objects - return op.Contains("Scan", StringComparison.OrdinalIgnoreCase) - || op.Contains("Seek", StringComparison.OrdinalIgnoreCase) - || op.Contains("Lookup", StringComparison.OrdinalIgnoreCase) - || op.Contains("Insert", StringComparison.OrdinalIgnoreCase) - || op.Contains("Update", StringComparison.OrdinalIgnoreCase) - || op.Contains("Delete", StringComparison.OrdinalIgnoreCase) - || op.Contains("Spool", StringComparison.OrdinalIgnoreCase); - } - - private void AddSchemaMenuItems(ContextMenu menu, PlanNode node) - { - if (string.IsNullOrEmpty(node.ObjectName) || IsTempObject(node.ObjectName)) - return; - if (!IsDataAccessOperator(node)) - return; - - var objectName = node.ObjectName; - - menu.Items.Add(new Separator()); - - var showIndexes = new MenuItem { Header = $"Show Indexes — {objectName}" }; - showIndexes.Click += async (_, _) => await FetchAndShowSchemaAsync("Indexes", objectName, - async cs => FormatIndexes(objectName, await SchemaQueryService.FetchIndexesAsync(cs, objectName))); - menu.Items.Add(showIndexes); - - var showTableDef = new MenuItem { Header = $"Show Table Definition — {objectName}" }; - showTableDef.Click += async (_, _) => await FetchAndShowSchemaAsync("Table", objectName, - async cs => - { - var columns = await SchemaQueryService.FetchColumnsAsync(cs, objectName); - var indexes = await SchemaQueryService.FetchIndexesAsync(cs, objectName); - return FormatColumns(objectName, columns, indexes); - }); - menu.Items.Add(showTableDef); - - // Disable schema items when no connection - menu.Opening += (_, _) => - { - var enabled = ConnectionString != null; - showIndexes.IsEnabled = enabled; - showTableDef.IsEnabled = enabled; - }; - } - - private async System.Threading.Tasks.Task FetchAndShowSchemaAsync( - string kind, string objectName, Func> fetch) - { - if (ConnectionString == null) return; - - try - { - var content = await fetch(ConnectionString); - ShowSchemaResult($"{kind} — {objectName}", content); - } - catch (Exception ex) - { - ShowSchemaResult($"Error — {objectName}", $"-- Error: {ex.Message}"); - } - } - - private void ShowSchemaResult(string title, string content) - { - var editor = new AvaloniaEdit.TextEditor - { - Text = content, - IsReadOnly = true, - FontFamily = new FontFamily("Consolas, Menlo, monospace"), - FontSize = 13, - ShowLineNumbers = true, - Background = FindBrushResource("BackgroundBrush"), - Foreground = FindBrushResource("ForegroundBrush"), - HorizontalScrollBarVisibility = ScrollBarVisibility.Auto, - VerticalScrollBarVisibility = ScrollBarVisibility.Auto, - Padding = new Thickness(4) - }; - - // SQL syntax highlighting - var registryOptions = new TextMateSharp.Grammars.RegistryOptions(TextMateSharp.Grammars.ThemeName.DarkPlus); - var tm = editor.InstallTextMate(registryOptions); - tm.SetGrammar(registryOptions.GetScopeByLanguageId("sql")); - - // Context menu - var copyItem = new MenuItem { Header = "Copy" }; - copyItem.Click += async (_, _) => - { - var clipboard = TopLevel.GetTopLevel(this)?.Clipboard; - if (clipboard == null) return; - var sel = editor.TextArea.Selection; - if (!sel.IsEmpty) - await clipboard.SetTextAsync(sel.GetText()); - }; - var copyAllItem = new MenuItem { Header = "Copy All" }; - copyAllItem.Click += async (_, _) => - { - var clipboard = TopLevel.GetTopLevel(this)?.Clipboard; - if (clipboard == null) return; - await clipboard.SetTextAsync(editor.Text); - }; - var selectAllItem = new MenuItem { Header = "Select All" }; - selectAllItem.Click += (_, _) => editor.SelectAll(); - editor.TextArea.ContextMenu = new ContextMenu - { - Items = { copyItem, copyAllItem, new Separator(), selectAllItem } - }; - - // Show in a popup window - var window = new Window - { - Title = $"Performance Studio — {title}", - Width = 700, - Height = 500, - MinWidth = 400, - MinHeight = 200, - Background = FindBrushResource("BackgroundBrush"), - Foreground = FindBrushResource("ForegroundBrush"), - Content = editor - }; - - var topLevel = TopLevel.GetTopLevel(this); - if (topLevel is Window parentWindow) - { - window.Icon = parentWindow.Icon; - window.Show(parentWindow); - } - else - { - window.Show(); - } - } // --- Formatters (same logic as QuerySessionControl) --- - 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--; - } #endregion } From aa02254659cad77bb421e1c4fbbf45b58ca3689c Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Tue, 12 May 2026 22:29:19 -0500 Subject: [PATCH 14/27] Split QuerySessionControl.axaml.cs into partial classes (#328) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move-only refactor; no behavior changes. QuerySessionControl.axaml.cs (2,251 lines) split into 8 partials: Editor (286) - syntax highlighting, context menu, key/wheel, zoom Schema (355) - ShowSchemaInfoAsync + Format{Indexes,Columns} + BuildWithOptions Connection (120) - Connect_Click + Populate/Fetch* metadata Execution (477) - Execute/EstimatedPlan/GetActualPlan + CaptureAndShowPlan Plans (368) - plan tab add/close/rename/context-menu + Compare picker QueryStore (267) - QueryStore_Click + Overview + OpenQueryStoreForDatabaseAsync Advice ( 24) - Human/Robot advice click + ShowAdviceWindow Format (124) - Format_Click + FormatOptions + CopyRepro Main file now 177 lines — usings, ctor, fields, AXAML wiring. Build clean: 0 errors, 0 warnings on PlanViewer.App. Co-authored-by: Claude Opus 4.7 (1M context) --- .../Controls/QuerySessionControl.Advice.cs | 60 + .../QuerySessionControl.Connection.cs | 159 ++ .../Controls/QuerySessionControl.Editor.cs | 333 +++ .../Controls/QuerySessionControl.Execution.cs | 516 ++++ .../Controls/QuerySessionControl.Format.cs | 159 ++ .../Controls/QuerySessionControl.Plans.cs | 410 ++++ .../QuerySessionControl.QueryStore.cs | 305 +++ .../Controls/QuerySessionControl.Schema.cs | 395 +++ .../Controls/QuerySessionControl.axaml.cs | 2122 +---------------- 9 files changed, 2361 insertions(+), 2098 deletions(-) create mode 100644 src/PlanViewer.App/Controls/QuerySessionControl.Advice.cs create mode 100644 src/PlanViewer.App/Controls/QuerySessionControl.Connection.cs create mode 100644 src/PlanViewer.App/Controls/QuerySessionControl.Editor.cs create mode 100644 src/PlanViewer.App/Controls/QuerySessionControl.Execution.cs create mode 100644 src/PlanViewer.App/Controls/QuerySessionControl.Format.cs create mode 100644 src/PlanViewer.App/Controls/QuerySessionControl.Plans.cs create mode 100644 src/PlanViewer.App/Controls/QuerySessionControl.QueryStore.cs create mode 100644 src/PlanViewer.App/Controls/QuerySessionControl.Schema.cs diff --git a/src/PlanViewer.App/Controls/QuerySessionControl.Advice.cs b/src/PlanViewer.App/Controls/QuerySessionControl.Advice.cs new file mode 100644 index 0000000..f5c96f1 --- /dev/null +++ b/src/PlanViewer.App/Controls/QuerySessionControl.Advice.cs @@ -0,0 +1,60 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using System.Xml.Linq; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Input.Platform; +using Avalonia.Interactivity; +using Avalonia.Layout; +using Avalonia.Media; +using AvaloniaEdit; +using AvaloniaEdit.CodeCompletion; +using AvaloniaEdit.TextMate; +using Microsoft.Data.SqlClient; +using PlanViewer.App.Dialogs; +using PlanViewer.App.Services; +using PlanViewer.Core.Interfaces; +using PlanViewer.Core.Models; +using PlanViewer.Core.Output; +using PlanViewer.Core.Services; +using TextMateSharp.Grammars; + +namespace PlanViewer.App.Controls; + +public partial class QuerySessionControl : UserControl +{ + private AnalysisResult? GetCurrentAnalysis() + { + return GetCurrentAnalysisWithViewer().Analysis; + } + + private void HumanAdvice_Click(object? sender, RoutedEventArgs e) + { + var (analysis, viewer) = GetCurrentAnalysisWithViewer(); + if (analysis == null) { SetStatus("No plan to analyze", autoClear: false); return; } + + var text = TextFormatter.Format(analysis); + ShowAdviceWindow("Advice for Humans", text, analysis, viewer); + } + + private void RobotAdvice_Click(object? sender, RoutedEventArgs e) + { + var analysis = GetCurrentAnalysis(); + if (analysis == null) { SetStatus("No plan to analyze", autoClear: false); return; } + + var json = JsonSerializer.Serialize(analysis, new JsonSerializerOptions { WriteIndented = true }); + ShowAdviceWindow("Advice for Robots", json); + } + + private void ShowAdviceWindow(string title, string content, AnalysisResult? analysis = null, PlanViewerControl? sourceViewer = null) + { + AdviceWindowHelper.Show(GetParentWindow(), title, content, analysis, sourceViewer); + } +} diff --git a/src/PlanViewer.App/Controls/QuerySessionControl.Connection.cs b/src/PlanViewer.App/Controls/QuerySessionControl.Connection.cs new file mode 100644 index 0000000..85d4b71 --- /dev/null +++ b/src/PlanViewer.App/Controls/QuerySessionControl.Connection.cs @@ -0,0 +1,159 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using System.Xml.Linq; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Input.Platform; +using Avalonia.Interactivity; +using Avalonia.Layout; +using Avalonia.Media; +using AvaloniaEdit; +using AvaloniaEdit.CodeCompletion; +using AvaloniaEdit.TextMate; +using Microsoft.Data.SqlClient; +using PlanViewer.App.Dialogs; +using PlanViewer.App.Services; +using PlanViewer.Core.Interfaces; +using PlanViewer.Core.Models; +using PlanViewer.Core.Output; +using PlanViewer.Core.Services; +using TextMateSharp.Grammars; + +namespace PlanViewer.App.Controls; + +public partial class QuerySessionControl : UserControl +{ + private async void Connect_Click(object? sender, RoutedEventArgs e) + { + await ShowConnectionDialogAsync(); + } + + private async Task ShowConnectionDialogAsync() + { + var dialog = new ConnectionDialog(_credentialService, _connectionStore); + var result = await dialog.ShowDialog(GetParentWindow()); + + if (result == true && dialog.ResultConnection != null) + { + _serverConnection = dialog.ResultConnection; + _selectedDatabase = dialog.ResultDatabase; + _connectionString = _serverConnection.GetConnectionString(_credentialService, _selectedDatabase); + + ServerLabel.Text = _serverConnection.ApplicationIntentReadOnly + ? $"{_serverConnection.ServerName} (Read-only)" + : _serverConnection.ServerName; + ServerLabel.Foreground = Brushes.LimeGreen; + ConnectButton.Content = "Reconnect"; + + await PopulateDatabases(); + await FetchServerMetadataAsync(); + await FetchServerUtcOffset(); + + if (_selectedDatabase != null) + { + for (int i = 0; i < DatabaseBox.Items.Count; i++) + { + if (DatabaseBox.Items[i]?.ToString() == _selectedDatabase) + { + DatabaseBox.SelectedIndex = i; + break; + } + } + } + + await FetchDatabaseMetadataAsync(); + + ExecuteButton.IsEnabled = true; + ExecuteEstButton.IsEnabled = true; + } + } + + private async Task PopulateDatabases() + { + if (_serverConnection == null) return; + + try + { + var connStr = _serverConnection.GetConnectionString(_credentialService, "master"); + await using var conn = new SqlConnection(connStr); + await conn.OpenAsync(); + + var databases = new List(); + using var cmd = new SqlCommand( + "SELECT name FROM sys.databases WHERE state_desc = 'ONLINE' ORDER BY name", conn); + using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + databases.Add(reader.GetString(0)); + + DatabaseBox.ItemsSource = databases; + DatabaseBox.IsEnabled = true; + } + catch + { + DatabaseBox.IsEnabled = false; + } + } + + private async void Database_SelectionChanged(object? sender, SelectionChangedEventArgs e) + { + if (_serverConnection == null || DatabaseBox.SelectedItem == null) return; + + _selectedDatabase = DatabaseBox.SelectedItem.ToString(); + _connectionString = _serverConnection.GetConnectionString(_credentialService, _selectedDatabase); + + // Refresh database metadata for the new context + await FetchDatabaseMetadataAsync(); + } + + private async Task FetchServerMetadataAsync() + { + if (_connectionString == null) return; + try + { + _serverMetadata = await ServerMetadataService.FetchServerMetadataAsync( + _connectionString, IsAzureConnection); + } + catch + { + // Non-fatal — advice will just lack server context + _serverMetadata = null; + } + } + + private async Task FetchServerUtcOffset() + { + if (_connectionString == null) return; + try + { + await using var conn = new SqlConnection(_connectionString); + await conn.OpenAsync(); + await using var cmd = new SqlCommand( + "SELECT DATEDIFF(MINUTE, GETUTCDATE(), GETDATE())", conn); + var offset = await cmd.ExecuteScalarAsync(); + if (offset is int mins) + PlanViewer.Core.Services.TimeDisplayHelper.ServerUtcOffsetMinutes = mins; + } + catch { } + } + + private async Task FetchDatabaseMetadataAsync() + { + if (_connectionString == null || _serverMetadata == null) return; + try + { + _serverMetadata.Database = await ServerMetadataService.FetchDatabaseMetadataAsync( + _connectionString, _serverMetadata.SupportsScopedConfigs); + } + catch + { + // Non-fatal — advice will just lack database context + } + } +} diff --git a/src/PlanViewer.App/Controls/QuerySessionControl.Editor.cs b/src/PlanViewer.App/Controls/QuerySessionControl.Editor.cs new file mode 100644 index 0000000..883292d --- /dev/null +++ b/src/PlanViewer.App/Controls/QuerySessionControl.Editor.cs @@ -0,0 +1,333 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using System.Xml.Linq; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Input.Platform; +using Avalonia.Interactivity; +using Avalonia.Layout; +using Avalonia.Media; +using AvaloniaEdit; +using AvaloniaEdit.CodeCompletion; +using AvaloniaEdit.TextMate; +using Microsoft.Data.SqlClient; +using PlanViewer.App.Dialogs; +using PlanViewer.App.Services; +using PlanViewer.Core.Interfaces; +using PlanViewer.Core.Models; +using PlanViewer.Core.Output; +using PlanViewer.Core.Services; +using TextMateSharp.Grammars; + +namespace PlanViewer.App.Controls; + +public partial class QuerySessionControl : UserControl +{ + private void SetupSyntaxHighlighting() + { + var registryOptions = new RegistryOptions(ThemeName.DarkPlus); + _textMateInstallation = QueryEditor.InstallTextMate(registryOptions); + _textMateInstallation.SetGrammar(registryOptions.GetScopeByLanguageId("sql")); + } + + private void SetupEditorContextMenu() + { + var cutItem = new MenuItem { Header = "Cut" }; + cutItem.Click += async (_, _) => + { + var clipboard = TopLevel.GetTopLevel(this)?.Clipboard; + if (clipboard == null) return; + var selection = QueryEditor.TextArea.Selection; + if (selection.IsEmpty) return; + var text = selection.GetText(); + await clipboard.SetTextAsync(text); + selection.ReplaceSelectionWithText(""); + }; + + var copyItem = new MenuItem { Header = "Copy" }; + copyItem.Click += async (_, _) => + { + var clipboard = TopLevel.GetTopLevel(this)?.Clipboard; + if (clipboard == null) return; + var selection = QueryEditor.TextArea.Selection; + if (selection.IsEmpty) return; + await clipboard.SetTextAsync(selection.GetText()); + }; + + var pasteItem = new MenuItem { Header = "Paste" }; + pasteItem.Click += async (_, _) => + { + var clipboard = TopLevel.GetTopLevel(this)?.Clipboard; + if (clipboard == null) return; + var text = await clipboard.TryGetTextAsync(); + if (string.IsNullOrEmpty(text)) return; + QueryEditor.TextArea.PerformTextInput(text); + }; + + var selectAllItem = new MenuItem { Header = "Select All" }; + selectAllItem.Click += (_, _) => + { + QueryEditor.SelectAll(); + }; + + var executeFromCursorItem = new MenuItem { Header = "Execute from Cursor" }; + executeFromCursorItem.Click += async (_, _) => + { + var text = GetTextFromCursor(); + if (!string.IsNullOrWhiteSpace(text)) + await CaptureAndShowPlan(estimated: false, queryTextOverride: text); + }; + + var executeCurrentBatchItem = new MenuItem { Header = "Execute Current Batch" }; + executeCurrentBatchItem.Click += async (_, _) => + { + var text = GetCurrentBatch(); + if (!string.IsNullOrWhiteSpace(text)) + await CaptureAndShowPlan(estimated: false, queryTextOverride: text); + }; + + // Schema lookup items + _schemaSeparator = new Separator(); + + _showIndexesItem = new MenuItem { Header = "Show Indexes" }; + _showIndexesItem.Click += async (_, _) => await ShowSchemaInfoAsync(SchemaInfoKind.Indexes); + + _showTableDefItem = new MenuItem { Header = "Show Table Definition" }; + _showTableDefItem.Click += async (_, _) => await ShowSchemaInfoAsync(SchemaInfoKind.TableDefinition); + + _showObjectDefItem = new MenuItem { Header = "Show Object Definition" }; + _showObjectDefItem.Click += async (_, _) => await ShowSchemaInfoAsync(SchemaInfoKind.ObjectDefinition); + + var contextMenu = new ContextMenu + { + Items = + { + cutItem, copyItem, pasteItem, + new Separator(), selectAllItem, + new Separator(), executeFromCursorItem, executeCurrentBatchItem, + _schemaSeparator, + _showIndexesItem, _showTableDefItem, _showObjectDefItem + } + }; + + contextMenu.Opening += OnContextMenuOpening; + QueryEditor.TextArea.ContextMenu = contextMenu; + + // Move caret to right-click position so schema lookup resolves the clicked object + QueryEditor.TextArea.PointerPressed += OnEditorPointerPressed; + } + + private void OnEditorPointerPressed(object? sender, Avalonia.Input.PointerPressedEventArgs e) + { + if (!e.GetCurrentPoint(QueryEditor.TextArea).Properties.IsRightButtonPressed) + return; + + var pos = QueryEditor.GetPositionFromPoint(e.GetPosition(QueryEditor)); + if (pos == null) return; + + QueryEditor.TextArea.Caret.Position = pos.Value; + } + + private void OnContextMenuOpening(object? sender, System.ComponentModel.CancelEventArgs e) + { + // Resolve what object is under the cursor + var sqlText = QueryEditor.Text; + var offset = QueryEditor.CaretOffset; + _contextMenuObject = SqlObjectResolver.Resolve(sqlText, offset); + + var hasConnection = _connectionString != null; + var hasObject = _contextMenuObject != null && hasConnection; + + _schemaSeparator!.IsVisible = hasObject; + _showIndexesItem!.IsVisible = hasObject && _contextMenuObject!.Kind is SqlObjectKind.Table or SqlObjectKind.Unknown; + _showTableDefItem!.IsVisible = hasObject && _contextMenuObject!.Kind is SqlObjectKind.Table or SqlObjectKind.Unknown; + _showObjectDefItem!.IsVisible = hasObject && _contextMenuObject!.Kind is SqlObjectKind.Function or SqlObjectKind.Procedure; + + // Update headers to show the object name + if (hasObject) + { + var name = _contextMenuObject!.FullName; + _showIndexesItem.Header = $"Show Indexes — {name}"; + _showTableDefItem.Header = $"Show Table Definition — {name}"; + _showObjectDefItem.Header = $"Show Object Definition — {name}"; + } + } + + private void OnOpenInEditorRequested(object? sender, string queryText) + { + QueryEditor.Text = queryText; + SubTabControl.SelectedIndex = 0; // Switch to the editor tab + QueryEditor.Focus(); + } + + private void OnKeyDown(object? sender, KeyEventArgs e) + { + // F5 or Ctrl+E → Execute (actual plan) + if ((e.Key == Key.F5 || (e.Key == Key.E && e.KeyModifiers == KeyModifiers.Control)) + && ExecuteButton.IsEnabled) + { + Execute_Click(this, new RoutedEventArgs()); + e.Handled = true; + } + // Ctrl+L → Estimated plan + else if (e.Key == Key.L && e.KeyModifiers == KeyModifiers.Control + && ExecuteEstButton.IsEnabled) + { + ExecuteEstimated_Click(this, new RoutedEventArgs()); + e.Handled = true; + } + // Escape → Cancel running query + else if (e.Key == Key.Escape && _executionCts != null && !_executionCts.IsCancellationRequested) + { + _executionCts.Cancel(); + e.Handled = true; + } + } + + private void OnEditorPointerWheel(object? sender, PointerWheelEventArgs e) + { + if (e.KeyModifiers != KeyModifiers.Control) return; + + var delta = e.Delta.Y > 0 ? 1 : -1; + var newSize = QueryEditor.FontSize + delta; + QueryEditor.FontSize = Math.Clamp(newSize, 7, 52); + SyncZoomDropdown(); + e.Handled = true; + } + + private void Zoom_SelectionChanged(object? sender, SelectionChangedEventArgs e) + { + if (ZoomBox.SelectedItem is ComboBoxItem item && item.Tag is string tagStr + && int.TryParse(tagStr, out var size)) + { + QueryEditor.FontSize = size; + } + } + + private void SyncZoomDropdown() + { + // Find the closest matching zoom level + var fontSize = (int)Math.Round(QueryEditor.FontSize); + int bestIdx = 2; // default 100% + int bestDist = int.MaxValue; + + for (int i = 0; i < ZoomBox.Items.Count; i++) + { + if (ZoomBox.Items[i] is ComboBoxItem item && item.Tag is string tagStr + && int.TryParse(tagStr, out var size)) + { + var dist = Math.Abs(size - fontSize); + if (dist < bestDist) { bestDist = dist; bestIdx = i; } + } + } + + ZoomBox.SelectionChanged -= Zoom_SelectionChanged; + ZoomBox.SelectedIndex = bestIdx; + ZoomBox.SelectionChanged += Zoom_SelectionChanged; + } + + private void OnTextEntering(object? sender, TextInputEventArgs e) + { + if (_completionWindow == null || string.IsNullOrEmpty(e.Text)) return; + + // If the user types a non-identifier character, let the completion window + // decide whether to commit (it handles Tab/Enter/Space automatically) + var ch = e.Text[0]; + if (!char.IsLetterOrDigit(ch) && ch != '_') + { + _completionWindow.CompletionList.RequestInsertion(e); + } + } + + private void OnTextEntered(object? sender, TextInputEventArgs e) + { + if (_completionWindow != null) return; + if (string.IsNullOrEmpty(e.Text) || !char.IsLetter(e.Text[0])) return; + + var (prefix, wordStart) = GetWordBeforeCaret(); + if (prefix.Length < 2) return; + + var matches = SqlKeywords.All + .Where(k => k.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + .ToArray(); + + if (matches.Length == 0) return; + + _completionWindow = new CompletionWindow(QueryEditor.TextArea); + _completionWindow.StartOffset = wordStart; + _completionWindow.Closed += (_, _) => _completionWindow = null; + + foreach (var kw in matches) + _completionWindow.CompletionList.CompletionData.Add(new SqlCompletionData(kw)); + + _completionWindow.Show(); + } + + private string? GetSelectedTextOrNull() + { + var selection = QueryEditor.TextArea.Selection; + if (selection.IsEmpty) return null; + return selection.GetText(); + } + + private string GetTextFromCursor() + { + var doc = QueryEditor.Document; + var offset = QueryEditor.CaretOffset; + return doc.GetText(offset, doc.TextLength - offset); + } + + private string? GetCurrentBatch() + { + var doc = QueryEditor.Document; + var caretOffset = QueryEditor.CaretOffset; + var text = doc.Text; + var goPattern = new Regex(@"^\s*GO\s*$", RegexOptions.IgnoreCase | RegexOptions.Multiline); + var matches = goPattern.Matches(text); + + int batchStart = 0; + int batchEnd = text.Length; + + foreach (Match m in matches) + { + if (m.Index + m.Length <= caretOffset) + { + batchStart = m.Index + m.Length; + } + else if (m.Index >= caretOffset) + { + batchEnd = m.Index; + break; + } + } + + return text[batchStart..batchEnd].Trim(); + } + + private void SetStatus(string text, bool autoClear = true) + { + var old = _statusClearCts; + _statusClearCts = null; + old?.Cancel(); + old?.Dispose(); + + StatusText.Text = text; + + if (autoClear && !string.IsNullOrEmpty(text)) + { + var cts = new CancellationTokenSource(); + _statusClearCts = cts; + _ = Task.Delay(3000, cts.Token).ContinueWith(_ => + { + Avalonia.Threading.Dispatcher.UIThread.Post(() => StatusText.Text = ""); + }, TaskContinuationOptions.OnlyOnRanToCompletion); + } + } +} diff --git a/src/PlanViewer.App/Controls/QuerySessionControl.Execution.cs b/src/PlanViewer.App/Controls/QuerySessionControl.Execution.cs new file mode 100644 index 0000000..45f0bd7 --- /dev/null +++ b/src/PlanViewer.App/Controls/QuerySessionControl.Execution.cs @@ -0,0 +1,516 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using System.Xml.Linq; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Input.Platform; +using Avalonia.Interactivity; +using Avalonia.Layout; +using Avalonia.Media; +using AvaloniaEdit; +using AvaloniaEdit.CodeCompletion; +using AvaloniaEdit.TextMate; +using Microsoft.Data.SqlClient; +using PlanViewer.App.Dialogs; +using PlanViewer.App.Services; +using PlanViewer.Core.Interfaces; +using PlanViewer.Core.Models; +using PlanViewer.Core.Output; +using PlanViewer.Core.Services; +using TextMateSharp.Grammars; + +namespace PlanViewer.App.Controls; + +public partial class QuerySessionControl : UserControl +{ + private async void Execute_Click(object? sender, RoutedEventArgs e) + { + await CaptureAndShowPlan(estimated: false); + } + + private async void ExecuteEstimated_Click(object? sender, RoutedEventArgs e) + { + await CaptureAndShowPlan(estimated: true); + } + + private async Task CaptureAndShowPlan(bool estimated, string? queryTextOverride = null) + { + if (_serverConnection == null || _selectedDatabase == null) + { + SetStatus("Connect to a server first", autoClear: false); + return; + } + + // Always rebuild connection string from current database selection + // to guarantee the picker state is reflected at execution time + _connectionString = _serverConnection.GetConnectionString(_credentialService, _selectedDatabase); + + var queryText = queryTextOverride?.Trim() + ?? GetSelectedTextOrNull()?.Trim() + ?? QueryEditor.Text?.Trim(); + if (string.IsNullOrEmpty(queryText)) + { + SetStatus("Enter a query", autoClear: false); + return; + } + + _executionCts?.Cancel(); + _executionCts?.Dispose(); + _executionCts = new CancellationTokenSource(); + var ct = _executionCts.Token; + + var planType = estimated ? "Estimated" : "Actual"; + + // Create loading tab with cancel button + var loadingPanel = new StackPanel + { + VerticalAlignment = VerticalAlignment.Center, + HorizontalAlignment = HorizontalAlignment.Center, + Width = 300 + }; + + var progressBar = new ProgressBar + { + IsIndeterminate = true, + Height = 4, + Margin = new Avalonia.Thickness(0, 0, 0, 12) + }; + + var statusLabel = new TextBlock + { + Text = $"Capturing {planType.ToLower()} plan...", + FontSize = 14, + Foreground = new SolidColorBrush(Color.Parse("#E4E6EB")), + HorizontalAlignment = HorizontalAlignment.Center + }; + + var cancelBtn = new Button + { + Content = "\u25A0 Cancel", + Height = 32, + Width = 120, + Padding = new Avalonia.Thickness(16, 0), + FontSize = 13, + Margin = new Avalonia.Thickness(0, 16, 0, 0), + HorizontalAlignment = HorizontalAlignment.Center, + HorizontalContentAlignment = HorizontalAlignment.Center, + VerticalContentAlignment = VerticalAlignment.Center, + Theme = (Avalonia.Styling.ControlTheme)this.FindResource("AppButton")! + }; + cancelBtn.Click += (_, _) => _executionCts?.Cancel(); + + loadingPanel.Children.Add(progressBar); + loadingPanel.Children.Add(statusLabel); + loadingPanel.Children.Add(cancelBtn); + + var loadingContainer = new Grid + { + Background = new SolidColorBrush(Color.Parse("#1A1D23")), + Focusable = true, + Children = { loadingPanel } + }; + loadingContainer.KeyDown += (_, ke) => + { + if (ke.Key == Key.Escape) { _executionCts?.Cancel(); ke.Handled = true; } + }; + + // Add loading tab and switch to it + _planCounter++; + var tabLabel = estimated ? $"Est Plan {_planCounter}" : $"Plan {_planCounter}"; + var headerText = new TextBlock + { + Text = tabLabel, + VerticalAlignment = VerticalAlignment.Center, + FontSize = 12 + }; + var closeBtn = new Button + { + Content = "\u2715", + MinWidth = 22, MinHeight = 22, Width = 22, Height = 22, + Padding = new Avalonia.Thickness(0), + FontSize = 11, + Margin = new Avalonia.Thickness(6, 0, 0, 0), + Background = Brushes.Transparent, + BorderThickness = new Avalonia.Thickness(0), + Foreground = new SolidColorBrush(Color.FromRgb(0xE4, 0xE6, 0xEB)), + VerticalAlignment = VerticalAlignment.Center, + HorizontalContentAlignment = HorizontalAlignment.Center, + VerticalContentAlignment = VerticalAlignment.Center + }; + var header = new StackPanel + { + Orientation = Orientation.Horizontal, + Children = { headerText, closeBtn } + }; + var loadingTab = new TabItem { Header = header, Content = loadingContainer }; + closeBtn.Tag = loadingTab; + closeBtn.Click += ClosePlanTab_Click; + + SubTabControl.Items.Add(loadingTab); + SubTabControl.SelectedItem = loadingTab; + loadingContainer.Focus(); + + try + { + var sw = Stopwatch.StartNew(); + string? planXml; + + var isAzure = _serverConnection!.ServerName.Contains(".database.windows.net", + StringComparison.OrdinalIgnoreCase) || + _serverConnection.ServerName.Contains(".database.azure.com", + StringComparison.OrdinalIgnoreCase); + + if (estimated) + { + planXml = await EstimatedPlanExecutor.GetEstimatedPlanAsync( + _connectionString, _selectedDatabase, queryText, timeoutSeconds: 0, ct); + } + else + { + planXml = await ActualPlanExecutor.ExecuteForActualPlanAsync( + _connectionString, _selectedDatabase, queryText, + planXml: null, isolationLevel: null, + isAzureSqlDb: isAzure, timeoutSeconds: 0, ct); + } + + sw.Stop(); + + if (string.IsNullOrEmpty(planXml)) + { + statusLabel.Text = $"No plan returned ({sw.Elapsed.TotalSeconds:F1}s)"; + progressBar.IsVisible = false; + cancelBtn.IsVisible = false; + return; + } + + // Replace loading content with the plan viewer + SetStatus($"{planType} plan captured ({sw.Elapsed.TotalSeconds:F1}s)"); + var viewer = new PlanViewerControl(); + viewer.Metadata = _serverMetadata; + viewer.ConnectionString = _connectionString; + viewer.SetConnectionServices(_credentialService, _connectionStore); + if (_serverConnection != null) + viewer.SetConnectionStatus(_serverConnection.ServerName, _selectedDatabase); + viewer.OpenInEditorRequested += OnOpenInEditorRequested; + viewer.LoadPlan(planXml, tabLabel, queryText); + loadingTab.Content = viewer; + HumanAdviceButton.IsEnabled = true; + RobotAdviceButton.IsEnabled = true; + } + catch (OperationCanceledException) + { + SetStatus("Cancelled"); + SubTabControl.Items.Remove(loadingTab); + } + catch (SqlException ex) + { + statusLabel.Text = ex.Message.Length > 100 ? ex.Message[..100] + "..." : ex.Message; + progressBar.IsVisible = false; + cancelBtn.IsVisible = false; + } + catch (Exception ex) + { + statusLabel.Text = ex.Message.Length > 100 ? ex.Message[..100] + "..." : ex.Message; + progressBar.IsVisible = false; + cancelBtn.IsVisible = false; + } + } + + private async void GetActualPlan_Click(object? sender, RoutedEventArgs e) + { + var viewer = GetSelectedPlanViewer(); + if (viewer == null) + { + SetStatus("Select a plan tab first"); + return; + } + + if (_connectionString == null || _selectedDatabase == null) + { + SetStatus("Connect to a server first", autoClear: false); + return; + } + + var queryText = viewer.QueryText ?? ""; + var planXml = viewer.RawXml; + + if (string.IsNullOrEmpty(queryText)) + { + SetStatus("No query text available for this plan"); + return; + } + + /* Show confirmation dialog */ + var confirmed = await ShowConfirmationDialog( + "Get Actual Plan", + "The query will execute with SET STATISTICS XML ON to capture the actual plan.\n\nAll data results will be discarded.\n\nContinue?"); + + if (!confirmed) return; + + _executionCts?.Cancel(); + _executionCts?.Dispose(); + _executionCts = new CancellationTokenSource(); + var ct = _executionCts.Token; + + // Create loading tab with cancel button + var loadingPanel = new StackPanel + { + VerticalAlignment = VerticalAlignment.Center, + HorizontalAlignment = HorizontalAlignment.Center, + Width = 300 + }; + + var progressBar = new ProgressBar + { + IsIndeterminate = true, + Height = 4, + Margin = new Avalonia.Thickness(0, 0, 0, 12) + }; + + var statusLabel = new TextBlock + { + Text = "Capturing actual plan...", + FontSize = 14, + Foreground = new SolidColorBrush(Color.Parse("#E4E6EB")), + HorizontalAlignment = HorizontalAlignment.Center + }; + + var cancelBtn = new Button + { + Content = "\u25A0 Cancel", + Height = 32, + Width = 120, + Padding = new Avalonia.Thickness(16, 0), + FontSize = 13, + Margin = new Avalonia.Thickness(0, 16, 0, 0), + HorizontalAlignment = HorizontalAlignment.Center, + HorizontalContentAlignment = HorizontalAlignment.Center, + VerticalContentAlignment = VerticalAlignment.Center, + Theme = (Avalonia.Styling.ControlTheme)this.FindResource("AppButton")! + }; + cancelBtn.Click += (_, _) => _executionCts?.Cancel(); + + loadingPanel.Children.Add(progressBar); + loadingPanel.Children.Add(statusLabel); + loadingPanel.Children.Add(cancelBtn); + + var loadingContainer = new Grid + { + Background = new SolidColorBrush(Color.Parse("#1A1D23")), + Focusable = true, + Children = { loadingPanel } + }; + loadingContainer.KeyDown += (_, ke) => + { + if (ke.Key == Key.Escape) { _executionCts?.Cancel(); ke.Handled = true; } + }; + + _planCounter++; + var tabLabel = $"Plan {_planCounter}"; + var headerText = new TextBlock + { + Text = tabLabel, + VerticalAlignment = VerticalAlignment.Center, + FontSize = 12 + }; + var closeBtn = new Button + { + Content = "\u2715", + MinWidth = 22, MinHeight = 22, Width = 22, Height = 22, + Padding = new Avalonia.Thickness(0), + FontSize = 11, + Margin = new Avalonia.Thickness(6, 0, 0, 0), + Background = Brushes.Transparent, + BorderThickness = new Avalonia.Thickness(0), + Foreground = new SolidColorBrush(Color.FromRgb(0xE4, 0xE6, 0xEB)), + VerticalAlignment = VerticalAlignment.Center, + HorizontalContentAlignment = HorizontalAlignment.Center, + VerticalContentAlignment = VerticalAlignment.Center + }; + var header = new StackPanel + { + Orientation = Orientation.Horizontal, + Children = { headerText, closeBtn } + }; + var loadingTab = new TabItem { Header = header, Content = loadingContainer }; + closeBtn.Tag = loadingTab; + closeBtn.Click += ClosePlanTab_Click; + + SubTabControl.Items.Add(loadingTab); + SubTabControl.SelectedItem = loadingTab; + loadingContainer.Focus(); + + try + { + var sw = Stopwatch.StartNew(); + var isAzure = IsAzureConnection; + + var actualPlanXml = await ActualPlanExecutor.ExecuteForActualPlanAsync( + _connectionString, _selectedDatabase, queryText, + planXml, isolationLevel: null, + isAzureSqlDb: isAzure, timeoutSeconds: 0, ct); + + sw.Stop(); + + if (string.IsNullOrEmpty(actualPlanXml)) + { + statusLabel.Text = $"No actual plan returned ({sw.Elapsed.TotalSeconds:F1}s)"; + progressBar.IsVisible = false; + cancelBtn.IsVisible = false; + return; + } + + SetStatus($"Actual plan captured ({sw.Elapsed.TotalSeconds:F1}s)"); + var actualViewer = new PlanViewerControl(); + actualViewer.Metadata = _serverMetadata; + actualViewer.ConnectionString = _connectionString; + actualViewer.SetConnectionServices(_credentialService, _connectionStore); + if (_serverConnection != null) + actualViewer.SetConnectionStatus(_serverConnection.ServerName, _selectedDatabase); + actualViewer.OpenInEditorRequested += OnOpenInEditorRequested; + actualViewer.LoadPlan(actualPlanXml, tabLabel, queryText); + loadingTab.Content = actualViewer; + } + catch (OperationCanceledException) + { + SetStatus("Cancelled"); + SubTabControl.Items.Remove(loadingTab); + } + catch (SqlException ex) + { + statusLabel.Text = ex.Message.Length > 100 ? ex.Message[..100] + "..." : ex.Message; + progressBar.IsVisible = false; + cancelBtn.IsVisible = false; + } + catch (Exception ex) + { + statusLabel.Text = ex.Message.Length > 100 ? ex.Message[..100] + "..." : ex.Message; + progressBar.IsVisible = false; + cancelBtn.IsVisible = false; + } + finally + { + UpdatePlanTabButtonState(); + } + } + + /// + /// Shows a modal confirmation dialog and returns true if the user clicked OK. + /// + private async Task ShowConfirmationDialog(string title, string message) + { + var result = false; + + var messageText = new TextBlock + { + Text = message, + TextWrapping = TextWrapping.Wrap, + FontSize = 13, + Foreground = new SolidColorBrush(Color.Parse("#E4E6EB")), + Margin = new Avalonia.Thickness(0, 0, 0, 16) + }; + + var okBtn = new Button + { + Content = "OK", + Height = 32, + Width = 80, + Padding = new Avalonia.Thickness(16, 0), + FontSize = 12, + HorizontalContentAlignment = HorizontalAlignment.Center, + VerticalContentAlignment = VerticalAlignment.Center, + Theme = (Avalonia.Styling.ControlTheme)this.FindResource("AppButton")! + }; + + var cancelBtn = new Button + { + Content = "Cancel", + Height = 32, + Width = 80, + Padding = new Avalonia.Thickness(16, 0), + FontSize = 12, + Margin = new Avalonia.Thickness(8, 0, 0, 0), + HorizontalContentAlignment = HorizontalAlignment.Center, + VerticalContentAlignment = VerticalAlignment.Center, + Theme = (Avalonia.Styling.ControlTheme)this.FindResource("AppButton")! + }; + + var buttonPanel = new StackPanel + { + Orientation = Avalonia.Layout.Orientation.Horizontal, + HorizontalAlignment = HorizontalAlignment.Right + }; + buttonPanel.Children.Add(okBtn); + buttonPanel.Children.Add(cancelBtn); + + var content = new StackPanel + { + Margin = new Avalonia.Thickness(20), + Children = { messageText, buttonPanel } + }; + + var dialog = new Window + { + Title = title, + Width = 420, + Height = 200, + MinWidth = 420, + MinHeight = 200, + Icon = GetParentWindow().Icon, + Background = new SolidColorBrush(Color.Parse("#1A1D23")), + Foreground = new SolidColorBrush(Color.Parse("#E4E6EB")), + Content = content, + WindowStartupLocation = WindowStartupLocation.CenterOwner + }; + + okBtn.Click += (_, _) => { result = true; dialog.Close(); }; + cancelBtn.Click += (_, _) => dialog.Close(); + + await dialog.ShowDialog(GetParentWindow()); + return result; + } + + /// + /// Extracts the database name from plan XML's StmtSimple DatabaseContext attribute. + /// Returns null if not found. + /// + private static string? ExtractDatabaseFromPlanXml(string? planXml) + { + if (string.IsNullOrEmpty(planXml)) return null; + + try + { + var doc = XDocument.Parse(planXml); + XNamespace ns = "http://schemas.microsoft.com/sqlserver/2004/07/showplan"; + + /* Try StmtSimple first — most queries have this */ + var stmt = doc.Descendants(ns + "StmtSimple").FirstOrDefault(); + var dbContext = stmt?.Attribute("DatabaseContext")?.Value; + + if (!string.IsNullOrEmpty(dbContext)) + { + /* DatabaseContext is typically "[dbname]" — strip brackets */ + return dbContext.Trim('[', ']'); + } + } + catch + { + /* XML parse failure — fall through to null */ + } + + return null; + } + + private Window GetParentWindow() + { + var parent = this.VisualRoot; + return parent as Window ?? throw new InvalidOperationException("No parent window"); + } +} diff --git a/src/PlanViewer.App/Controls/QuerySessionControl.Format.cs b/src/PlanViewer.App/Controls/QuerySessionControl.Format.cs new file mode 100644 index 0000000..9ead789 --- /dev/null +++ b/src/PlanViewer.App/Controls/QuerySessionControl.Format.cs @@ -0,0 +1,159 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using System.Xml.Linq; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Input.Platform; +using Avalonia.Interactivity; +using Avalonia.Layout; +using Avalonia.Media; +using AvaloniaEdit; +using AvaloniaEdit.CodeCompletion; +using AvaloniaEdit.TextMate; +using Microsoft.Data.SqlClient; +using PlanViewer.App.Dialogs; +using PlanViewer.App.Services; +using PlanViewer.Core.Interfaces; +using PlanViewer.Core.Models; +using PlanViewer.Core.Output; +using PlanViewer.Core.Services; +using TextMateSharp.Grammars; + +namespace PlanViewer.App.Controls; + +public partial class QuerySessionControl : UserControl +{ + private async void CopyRepro_Click(object? sender, RoutedEventArgs e) + { + var viewer = GetSelectedPlanViewer(); + if (viewer == null) + { + SetStatus("Select a plan tab first"); + return; + } + + var planXml = viewer.RawXml; + var queryText = viewer.QueryText ?? ""; + + if (string.IsNullOrEmpty(queryText) && string.IsNullOrEmpty(planXml)) + { + SetStatus("No query or plan data available"); + return; + } + + /* Extract database name from plan XML StmtSimple/@DatabaseContext if available, + otherwise fall back to the currently selected database */ + var database = ExtractDatabaseFromPlanXml(planXml) ?? _selectedDatabase; + + var reproScript = ReproScriptBuilder.BuildReproScript( + queryText, + database, + planXml, + isolationLevel: null, + source: "Performance Studio", + isAzureSqlDb: IsAzureConnection); + + try + { + var topLevel = TopLevel.GetTopLevel(this); + if (topLevel?.Clipboard != null) + { + await topLevel.Clipboard.SetTextAsync(reproScript); + SetStatus("Repro script copied to clipboard"); + } + } + catch (Exception ex) + { + SetStatus($"Clipboard error: {ex.Message}"); + } + } + + private async void Format_Click(object? sender, RoutedEventArgs e) + { + var sql = QueryEditor.Text; + if (string.IsNullOrWhiteSpace(sql)) + return; + + FormatButton.IsEnabled = false; + SetStatus("Formatting..."); + + try + { + var settings = SqlFormatSettingsService.Load(out var loadError); + if (loadError != null) + SetStatus("Warning: using default format settings (load failed)"); + + var (formatted, errors) = await Task.Run(() => SqlFormattingService.Format(sql, settings)); + + if (errors != null && errors.Count > 0) + { + var errorMessages = string.Join("\n", errors.Select(err => $"Line {err.Line}: {err.Message}")); + var dialog = new Window + { + Title = "SQL Format Error", + Width = 500, + Height = 250, + WindowStartupLocation = WindowStartupLocation.CenterOwner, + Icon = GetParentWindow().Icon, + Background = (IBrush)this.FindResource("BackgroundBrush")!, + Foreground = (IBrush)this.FindResource("ForegroundBrush")!, + Content = new StackPanel + { + Margin = new Avalonia.Thickness(20), + Children = + { + new TextBlock + { + Text = $"Could not format: {errors.Count} parse error(s)", + FontWeight = Avalonia.Media.FontWeight.Bold, + FontSize = 14, + Margin = new Avalonia.Thickness(0, 0, 0, 10) + }, + new TextBlock + { + Text = errorMessages, + TextWrapping = TextWrapping.Wrap, + FontSize = 12 + } + } + } + }; + await dialog.ShowDialog(GetParentWindow()); + SetStatus($"Format failed: {errors.Count} error(s)"); + return; + } + + var caretOffset = QueryEditor.CaretOffset; + + QueryEditor.Document.BeginUpdate(); + try + { + QueryEditor.Document.Replace(0, QueryEditor.Document.TextLength, formatted); + } + finally + { + QueryEditor.Document.EndUpdate(); + } + + QueryEditor.CaretOffset = Math.Min(caretOffset, QueryEditor.Document.TextLength); + SetStatus("Formatted"); + } + finally + { + FormatButton.IsEnabled = true; + } + } + + private void FormatOptions_Click(object? sender, RoutedEventArgs e) + { + var dialog = new Dialogs.FormatOptionsWindow(); + dialog.ShowDialog(GetParentWindow()); + } +} diff --git a/src/PlanViewer.App/Controls/QuerySessionControl.Plans.cs b/src/PlanViewer.App/Controls/QuerySessionControl.Plans.cs new file mode 100644 index 0000000..6965639 --- /dev/null +++ b/src/PlanViewer.App/Controls/QuerySessionControl.Plans.cs @@ -0,0 +1,410 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using System.Xml.Linq; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Input.Platform; +using Avalonia.Interactivity; +using Avalonia.Layout; +using Avalonia.Media; +using AvaloniaEdit; +using AvaloniaEdit.CodeCompletion; +using AvaloniaEdit.TextMate; +using Microsoft.Data.SqlClient; +using PlanViewer.App.Dialogs; +using PlanViewer.App.Services; +using PlanViewer.Core.Interfaces; +using PlanViewer.Core.Models; +using PlanViewer.Core.Output; +using PlanViewer.Core.Services; +using TextMateSharp.Grammars; + +namespace PlanViewer.App.Controls; + +public partial class QuerySessionControl : UserControl +{ + private void AddPlanTab(string planXml, string queryText, bool estimated, string? labelOverride = null) + { + _planCounter++; + var label = labelOverride ?? (estimated ? $"Est Plan {_planCounter}" : $"Plan {_planCounter}"); + + var viewer = new PlanViewerControl(); + viewer.Metadata = _serverMetadata; + viewer.ConnectionString = _connectionString; + viewer.SetConnectionServices(_credentialService, _connectionStore); + if (_serverConnection != null) + viewer.SetConnectionStatus(_serverConnection.ServerName, _selectedDatabase); + viewer.OpenInEditorRequested += OnOpenInEditorRequested; + viewer.LoadPlan(planXml, label, queryText); + + // Build tab header with close button and right-click rename + var headerText = new TextBlock + { + Text = label, + VerticalAlignment = VerticalAlignment.Center, + FontSize = 12 + }; + + var closeBtn = new Button + { + Content = "\u2715", + MinWidth = 22, + MinHeight = 22, + Width = 22, + Height = 22, + Padding = new Avalonia.Thickness(0), + FontSize = 11, + Margin = new Avalonia.Thickness(6, 0, 0, 0), + Background = Brushes.Transparent, + BorderThickness = new Avalonia.Thickness(0), + Foreground = new SolidColorBrush(Color.FromRgb(0xE4, 0xE6, 0xEB)), + VerticalAlignment = VerticalAlignment.Center, + HorizontalContentAlignment = HorizontalAlignment.Center, + VerticalContentAlignment = VerticalAlignment.Center + }; + + var header = new StackPanel + { + Orientation = Orientation.Horizontal, + Children = { headerText, closeBtn } + }; + + var tab = new TabItem { Header = header, Content = viewer }; + closeBtn.Tag = tab; + closeBtn.Click += ClosePlanTab_Click; + + // Right-click context menu + var contextMenu = new ContextMenu + { + Items = + { + new MenuItem { Header = "Rename Tab", Tag = new object[] { header, headerText } }, + new Separator(), + new MenuItem { Header = "Close", Tag = tab, InputGesture = new KeyGesture(Key.W, KeyModifiers.Control) }, + new MenuItem { Header = "Close Other Tabs", Tag = tab }, + new MenuItem { Header = "Close All Tabs" } + } + }; + + foreach (var item in contextMenu.Items.OfType()) + item.Click += PlanTabContextMenu_Click; + + header.ContextMenu = contextMenu; + + SubTabControl.Items.Add(tab); + SubTabControl.SelectedItem = tab; + UpdateCompareButtonState(); + } + + private void StartRename(StackPanel header, TextBlock headerText) + { + var textBox = new TextBox + { + Text = headerText.Text, + FontSize = 12, + MinWidth = 80, + Padding = new Avalonia.Thickness(2, 0), + VerticalAlignment = VerticalAlignment.Center + }; + + headerText.IsVisible = false; + header.Children.Insert(0, textBox); + textBox.Focus(); + textBox.SelectAll(); + + void CommitRename() + { + var newName = textBox.Text?.Trim(); + if (!string.IsNullOrEmpty(newName)) + headerText.Text = newName; + + headerText.IsVisible = true; + header.Children.Remove(textBox); + } + + textBox.KeyDown += (_, ke) => + { + if (ke.Key == Key.Enter || ke.Key == Key.Escape) + { + if (ke.Key == Key.Escape) + textBox.Text = headerText.Text; + CommitRename(); + ke.Handled = true; + } + }; + + textBox.LostFocus += (_, _) => CommitRename(); + } + + private void ClosePlanTab_Click(object? sender, RoutedEventArgs e) + { + if (sender is Button btn && btn.Tag is TabItem tab) + { + if (tab.Content is PlanViewerControl viewer) + viewer.Clear(); + SubTabControl.Items.Remove(tab); + UpdateCompareButtonState(); + } + } + + private void PlanTabContextMenu_Click(object? sender, RoutedEventArgs e) + { + if (sender is not MenuItem item) return; + + switch (item.Header?.ToString()) + { + case "Rename Tab": + if (item.Tag is object[] parts) + StartRename((StackPanel)parts[0], (TextBlock)parts[1]); + break; + + case "Close": + if (item.Tag is TabItem tab) + { + if (tab.Content is PlanViewerControl closeViewer) + closeViewer.Clear(); + SubTabControl.Items.Remove(tab); + UpdateCompareButtonState(); + } + break; + + case "Close Other Tabs": + if (item.Tag is TabItem keepTab) + { + // Keep the Editor tab (index 0) and the selected tab + var others = SubTabControl.Items.Cast() + .OfType() + .Where(t => t != keepTab && t.Content is PlanViewerControl) + .ToList(); + foreach (var t in others) + { + if (t.Content is PlanViewerControl otherViewer) + otherViewer.Clear(); + SubTabControl.Items.Remove(t); + } + SubTabControl.SelectedItem = keepTab; + UpdateCompareButtonState(); + } + break; + + case "Close All Tabs": + var planTabs = SubTabControl.Items.Cast() + .OfType() + .Where(t => t.Content is PlanViewerControl) + .ToList(); + foreach (var t in planTabs) + { + if (t.Content is PlanViewerControl allViewer) + allViewer.Clear(); + SubTabControl.Items.Remove(t); + } + SubTabControl.SelectedIndex = 0; // back to Editor + UpdateCompareButtonState(); + break; + } + } + + private void UpdateCompareButtonState() + { + int planCount = 0; + foreach (var item in SubTabControl.Items) + { + if (item is TabItem t && t.Content is PlanViewerControl v && v.CurrentPlan != null) + planCount++; + } + ComparePlansButton.IsEnabled = planCount >= 2; + } + + private static string GetTabLabel(TabItem tab) + { + if (tab.Header is StackPanel sp && sp.Children.Count > 0 && sp.Children[0] is TextBlock tb) + return tb.Text ?? "Plan"; + if (tab.Header is string s) + return s; + return "Plan"; + } + + private void ComparePlans_Click(object? sender, RoutedEventArgs e) + { + var planTabs = GetPlanTabs().ToList(); + if (planTabs.Count < 2) + { + SetStatus("Need at least 2 plan tabs to compare"); + return; + } + + ShowComparePickerDialog(planTabs); + } + + private void ShowComparePickerDialog(List<(string label, PlanViewerControl viewer)> planTabs) + { + var items = planTabs.Select(t => t.label).ToList(); + + var comboA = new ComboBox + { + ItemsSource = items, + SelectedIndex = 0, + Width = 200, + Height = 28, + FontSize = 12, + Margin = new Avalonia.Thickness(8, 0, 0, 0) + }; + + var comboB = new ComboBox + { + ItemsSource = items, + SelectedIndex = items.Count > 1 ? 1 : 0, + Width = 200, + Height = 28, + FontSize = 12, + Margin = new Avalonia.Thickness(8, 0, 0, 0) + }; + + var compareBtn = new Button + { + Content = "Compare", + Height = 32, + Padding = new Avalonia.Thickness(16, 0), + FontSize = 12, + HorizontalContentAlignment = HorizontalAlignment.Center, + VerticalContentAlignment = VerticalAlignment.Center, + Theme = (Avalonia.Styling.ControlTheme)this.FindResource("AppButton")! + }; + + var cancelBtn = new Button + { + Content = "Cancel", + Height = 32, + Padding = new Avalonia.Thickness(16, 0), + FontSize = 12, + Margin = new Avalonia.Thickness(8, 0, 0, 0), + HorizontalContentAlignment = HorizontalAlignment.Center, + VerticalContentAlignment = VerticalAlignment.Center, + Theme = (Avalonia.Styling.ControlTheme)this.FindResource("AppButton")! + }; + + void UpdateCompareEnabled() + { + compareBtn.IsEnabled = comboA.SelectedIndex >= 0 && comboB.SelectedIndex >= 0 + && comboA.SelectedIndex != comboB.SelectedIndex; + } + + comboA.SelectionChanged += (_, _) => UpdateCompareEnabled(); + comboB.SelectionChanged += (_, _) => UpdateCompareEnabled(); + UpdateCompareEnabled(); + + var rowA = new StackPanel + { + Orientation = Orientation.Horizontal, + Margin = new Avalonia.Thickness(0, 0, 0, 8), + Children = + { + new TextBlock { Text = "Plan A:", VerticalAlignment = VerticalAlignment.Center, FontSize = 13, Width = 55 }, + comboA + } + }; + + var rowB = new StackPanel + { + Orientation = Orientation.Horizontal, + Children = + { + new TextBlock { Text = "Plan B:", VerticalAlignment = VerticalAlignment.Center, FontSize = 13, Width = 55 }, + comboB + } + }; + + var buttonPanel = new StackPanel + { + Orientation = Orientation.Horizontal, + HorizontalAlignment = HorizontalAlignment.Right, + Margin = new Avalonia.Thickness(0, 16, 0, 0), + Children = { compareBtn, cancelBtn } + }; + + var content = new StackPanel + { + Margin = new Avalonia.Thickness(20), + Children = + { + new TextBlock { Text = "Select two plans to compare:", FontSize = 14, Margin = new Avalonia.Thickness(0, 0, 0, 12) }, + rowA, + rowB, + buttonPanel + } + }; + + var dialog = new Window + { + Title = "Compare Plans", + Width = 380, + Height = 220, + MinWidth = 380, + MinHeight = 220, + Icon = GetParentWindow().Icon, + Background = new SolidColorBrush(Color.Parse("#1A1D23")), + Foreground = new SolidColorBrush(Color.Parse("#E4E6EB")), + Content = content, + WindowStartupLocation = WindowStartupLocation.CenterOwner + }; + + compareBtn.Click += (_, _) => + { + var idxA = comboA.SelectedIndex; + var idxB = comboB.SelectedIndex; + if (idxA < 0 || idxB < 0 || idxA == idxB) return; + + var (labelA, viewerA) = planTabs[idxA]; + var (labelB, viewerB) = planTabs[idxB]; + + var analysisA = ResultMapper.Map(viewerA.CurrentPlan!, "query editor", _serverMetadata); + var analysisB = ResultMapper.Map(viewerB.CurrentPlan!, "query editor", _serverMetadata); + + var comparison = ComparisonFormatter.Compare(analysisA, analysisB, labelA, labelB); + dialog.Close(); + ShowAdviceWindow("Plan Comparison", comparison); + }; + + cancelBtn.Click += (_, _) => dialog.Close(); + + dialog.ShowDialog(GetParentWindow()); + } + + /// + /// Gets the PlanViewerControl for the currently selected plan tab, or null if + /// the Editor tab or no plan tab is selected. + /// + private PlanViewerControl? GetSelectedPlanViewer() + { + if (SubTabControl.SelectedItem is TabItem tab && tab.Content is PlanViewerControl viewer + && viewer.CurrentPlan != null) + { + return viewer; + } + return null; + } + + /// + /// Enables or disables buttons that require a plan tab to be selected. + /// Called when the SubTabControl selection changes and after plan tabs are added/removed. + /// + private void UpdatePlanTabButtonState() + { + var hasPlanTab = GetSelectedPlanViewer() != null; + var hasConnection = _connectionString != null && _selectedDatabase != null; + + CopyReproButton.IsEnabled = hasPlanTab; + GetActualPlanButton.IsEnabled = hasPlanTab && hasConnection; + + // Advice buttons also depend on a plan being selected + HumanAdviceButton.IsEnabled = hasPlanTab; + RobotAdviceButton.IsEnabled = hasPlanTab; + } +} diff --git a/src/PlanViewer.App/Controls/QuerySessionControl.QueryStore.cs b/src/PlanViewer.App/Controls/QuerySessionControl.QueryStore.cs new file mode 100644 index 0000000..bf4d135 --- /dev/null +++ b/src/PlanViewer.App/Controls/QuerySessionControl.QueryStore.cs @@ -0,0 +1,305 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using System.Xml.Linq; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Input.Platform; +using Avalonia.Interactivity; +using Avalonia.Layout; +using Avalonia.Media; +using AvaloniaEdit; +using AvaloniaEdit.CodeCompletion; +using AvaloniaEdit.TextMate; +using Microsoft.Data.SqlClient; +using PlanViewer.App.Dialogs; +using PlanViewer.App.Services; +using PlanViewer.Core.Interfaces; +using PlanViewer.Core.Models; +using PlanViewer.Core.Output; +using PlanViewer.Core.Services; +using TextMateSharp.Grammars; + +namespace PlanViewer.App.Controls; + +public partial class QuerySessionControl : UserControl +{ + private bool HasQueryStoreTab() + { + return SubTabControl.Items.OfType() + .Any(t => t.Content is QueryStoreGridControl); + } + + public void TriggerQueryStore() => QueryStore_Click(null, new RoutedEventArgs()); + + private async void QueryStoreOverview_Click(object? sender, RoutedEventArgs e) + { + if (_serverConnection == null || _connectionString == null) + { + await ShowConnectionDialogAsync(); + if (_serverConnection == null || _connectionString == null) + return; + } + + SetStatus("Loading Query Store Overview..."); + + var supportsWaitStats = _serverMetadata?.SupportsQueryStoreWaitStats ?? false; + var overview = new QueryStoreOverviewControl(_serverConnection, _credentialService, + supportsWaitStats: supportsWaitStats); + overview.DrillDownRequested += async (_, args) => + { + // Open a single-database Query Store tab directly (no connection dialog) + _selectedDatabase = args.Database; + _connectionString = _serverConnection!.GetConnectionString(_credentialService, args.Database); + await OpenQueryStoreForDatabaseAsync(args.Database, args.StartUtc, args.EndUtc); + }; + + var headerText = new TextBlock + { + Text = "QS Overview", + VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center, + FontSize = 12 + }; + + var closeBtn = new Button + { + Content = "\u2715", + MinWidth = 22, MinHeight = 22, Width = 22, Height = 22, + Padding = new Avalonia.Thickness(0), + FontSize = 11, + Margin = new Avalonia.Thickness(6, 0, 0, 0), + Background = Brushes.Transparent, + BorderThickness = new Avalonia.Thickness(0), + Foreground = new SolidColorBrush(Color.FromRgb(0xE4, 0xE6, 0xEB)), + VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center, + HorizontalContentAlignment = HorizontalAlignment.Center, + VerticalContentAlignment = VerticalAlignment.Center + }; + + var header = new StackPanel + { + Orientation = Avalonia.Layout.Orientation.Horizontal, + Children = { headerText, closeBtn } + }; + + var tab = new TabItem { Header = header, Content = overview }; + closeBtn.Tag = tab; + closeBtn.Click += (s, _) => + { + if (s is Button btn && btn.Tag is TabItem t) + SubTabControl.Items.Remove(t); + }; + + SubTabControl.Items.Add(tab); + SubTabControl.SelectedItem = tab; + + try + { + await overview.LoadAsync(); + SetStatus(""); + } + catch (Exception ex) + { + SetStatus(ex.Message.Length > 80 ? ex.Message[..80] + "..." : ex.Message, autoClear: false); + } + } + + private async Task OpenQueryStoreForDatabaseAsync(string database, DateTime? initialStartUtc = null, DateTime? initialEndUtc = null) + { + var connStr = _serverConnection!.GetConnectionString(_credentialService, database); + + // Check if Query Store is enabled + SetStatus($"Checking Query Store on {database}..."); + try + { + var (enabled, state) = await QueryStoreService.CheckEnabledAsync(connStr); + if (!enabled) + { + SetStatus($"Query Store not enabled on {database} ({state ?? "unknown"})"); + return; + } + } + catch (Exception ex) + { + SetStatus(ex.Message.Length > 80 ? ex.Message[..80] + "..." : ex.Message, autoClear: false); + return; + } + + SetStatus(""); + + // Check if wait stats are supported + var supportsWaitStats = _serverMetadata?.SupportsQueryStoreWaitStats ?? false; + if (supportsWaitStats) + { + try + { + supportsWaitStats = await QueryStoreService.IsWaitStatsCaptureEnabledAsync(connStr); + } + catch { supportsWaitStats = false; } + } + + var databases = DatabaseBox.Items.OfType().ToList(); + + var grid = new QueryStoreGridControl(_serverConnection!, _credentialService, + database, databases, supportsWaitStats); + if (initialStartUtc.HasValue && initialEndUtc.HasValue) + grid.SetInitialTimeRange(initialStartUtc.Value, initialEndUtc.Value); + grid.PlansSelected += OnQueryStorePlansSelected; + + var headerText = new TextBlock + { + Text = $"Query Store — {database}", + VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center, + FontSize = 12 + }; + grid.DatabaseChanged += (_, db) => headerText.Text = $"Query Store — {db}"; + + var closeBtn = new Button + { + Content = "\u2715", + MinWidth = 22, MinHeight = 22, Width = 22, Height = 22, + Padding = new Avalonia.Thickness(0), + FontSize = 11, + Margin = new Avalonia.Thickness(6, 0, 0, 0), + Background = Brushes.Transparent, + BorderThickness = new Avalonia.Thickness(0), + Foreground = new SolidColorBrush(Color.FromRgb(0xE4, 0xE6, 0xEB)), + VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center, + HorizontalContentAlignment = HorizontalAlignment.Center, + VerticalContentAlignment = VerticalAlignment.Center + }; + + var header = new StackPanel + { + Orientation = Avalonia.Layout.Orientation.Horizontal, + Children = { headerText, closeBtn } + }; + + var tab = new TabItem { Header = header, Content = grid }; + closeBtn.Tag = tab; + closeBtn.Click += (s, _) => + { + if (s is Button btn && btn.Tag is TabItem t) + SubTabControl.Items.Remove(t); + }; + + SubTabControl.Items.Add(tab); + SubTabControl.SelectedItem = tab; + } + + private async void QueryStore_Click(object? sender, RoutedEventArgs e) + { + // If a QS tab already exists, always show connection dialog for a fresh tab + if (HasQueryStoreTab() || _connectionString == null || _selectedDatabase == null) + { + await ShowConnectionDialogAsync(); + if (_connectionString == null || _selectedDatabase == null) + return; + } + + // Check if Query Store is enabled + SetStatus("Checking Query Store..."); + try + { + var (enabled, state) = await QueryStoreService.CheckEnabledAsync(_connectionString); + if (!enabled) + { + SetStatus($"Query Store not enabled ({state ?? "unknown"})"); + return; + } + } + catch (Exception ex) + { + SetStatus(ex.Message.Length > 80 ? ex.Message[..80] + "..." : ex.Message, autoClear: false); + return; + } + + SetStatus(""); + + // Check if wait stats are supported (SQL 2017+ / Azure) and capture is enabled + var supportsWaitStats = _serverMetadata?.SupportsQueryStoreWaitStats ?? false; + if (supportsWaitStats) + { + try + { + var connStr = _serverConnection!.GetConnectionString(_credentialService, _selectedDatabase!); + supportsWaitStats = await QueryStoreService.IsWaitStatsCaptureEnabledAsync(connStr); + } + catch + { + supportsWaitStats = false; + } + } + + // Build database list from the current DatabaseBox + var databases = DatabaseBox.Items.OfType().ToList(); + + var grid = new QueryStoreGridControl(_serverConnection!, _credentialService, + _selectedDatabase!, databases, supportsWaitStats); + grid.PlansSelected += OnQueryStorePlansSelected; + + var headerText = new TextBlock + { + Text = $"Query Store — {_selectedDatabase}", + VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center, + FontSize = 12 + }; + + // Update tab header when database is changed via the grid's picker + grid.DatabaseChanged += (_, db) => + { + headerText.Text = $"Query Store — {db}"; + }; + + var closeBtn = new Button + { + Content = "\u2715", + MinWidth = 22, MinHeight = 22, Width = 22, Height = 22, + Padding = new Avalonia.Thickness(0), + FontSize = 11, + Margin = new Avalonia.Thickness(6, 0, 0, 0), + Background = Brushes.Transparent, + BorderThickness = new Avalonia.Thickness(0), + Foreground = new SolidColorBrush(Color.FromRgb(0xE4, 0xE6, 0xEB)), + VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center, + HorizontalContentAlignment = HorizontalAlignment.Center, + VerticalContentAlignment = VerticalAlignment.Center + }; + + var header = new StackPanel + { + Orientation = Avalonia.Layout.Orientation.Horizontal, + Children = { headerText, closeBtn } + }; + + var tab = new TabItem { Header = header, Content = grid }; + closeBtn.Tag = tab; + closeBtn.Click += (s, _) => + { + if (s is Button btn && btn.Tag is TabItem t) + SubTabControl.Items.Remove(t); + }; + + SubTabControl.Items.Add(tab); + SubTabControl.SelectedItem = tab; + } + + private void OnQueryStorePlansSelected(object? sender, List plans) + { + foreach (var qsPlan in plans) + { + var tabLabel = $"QS {qsPlan.QueryId} / {qsPlan.PlanId}"; + AddPlanTab(qsPlan.PlanXml, qsPlan.QueryText, estimated: true, labelOverride: tabLabel); + } + + SetStatus($"{plans.Count} Query Store plans loaded"); + HumanAdviceButton.IsEnabled = true; + RobotAdviceButton.IsEnabled = true; + } +} diff --git a/src/PlanViewer.App/Controls/QuerySessionControl.Schema.cs b/src/PlanViewer.App/Controls/QuerySessionControl.Schema.cs new file mode 100644 index 0000000..6f3ab1c --- /dev/null +++ b/src/PlanViewer.App/Controls/QuerySessionControl.Schema.cs @@ -0,0 +1,395 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using System.Xml.Linq; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Input.Platform; +using Avalonia.Interactivity; +using Avalonia.Layout; +using Avalonia.Media; +using AvaloniaEdit; +using AvaloniaEdit.CodeCompletion; +using AvaloniaEdit.TextMate; +using Microsoft.Data.SqlClient; +using PlanViewer.App.Dialogs; +using PlanViewer.App.Services; +using PlanViewer.Core.Interfaces; +using PlanViewer.Core.Models; +using PlanViewer.Core.Output; +using PlanViewer.Core.Services; +using TextMateSharp.Grammars; + +namespace PlanViewer.App.Controls; + +public partial class QuerySessionControl : UserControl +{ + private async Task ShowSchemaInfoAsync(SchemaInfoKind kind) + { + if (_contextMenuObject == null || _connectionString == null) return; + + var objectName = _contextMenuObject.FullName; + SetStatus($"Fetching {kind} for {objectName}...", autoClear: false); + + try + { + string content; + string tabLabel; + + switch (kind) + { + case SchemaInfoKind.Indexes: + var indexes = await SchemaQueryService.FetchIndexesAsync(_connectionString, objectName); + content = 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); + tabLabel = $"Table — {objectName}"; + break; + + case SchemaInfoKind.ObjectDefinition: + var definition = await SchemaQueryService.FetchObjectDefinitionAsync(_connectionString, objectName); + content = definition ?? $"-- No definition found for {objectName}"; + tabLabel = $"Definition — {objectName}"; + break; + + default: + return; + } + + AddSchemaTab(tabLabel, content, isSql: true); + SetStatus($"Loaded {kind} for {objectName}"); + } + catch (Exception ex) + { + SetStatus($"Error: {ex.Message}", autoClear: false); + Debug.WriteLine($"Schema lookup error: {ex}"); + } + } + + private void AddSchemaTab(string label, string content, bool isSql) + { + var editor = new TextEditor + { + Text = content, + IsReadOnly = true, + FontFamily = new FontFamily("Consolas, Menlo, monospace"), + FontSize = 13, + ShowLineNumbers = true, + Background = (IBrush)this.FindResource("BackgroundBrush")!, + Foreground = (IBrush)this.FindResource("ForegroundBrush")!, + HorizontalScrollBarVisibility = Avalonia.Controls.Primitives.ScrollBarVisibility.Auto, + VerticalScrollBarVisibility = Avalonia.Controls.Primitives.ScrollBarVisibility.Auto, + Padding = new Avalonia.Thickness(4) + }; + + if (isSql) + { + var registryOptions = new RegistryOptions(ThemeName.DarkPlus); + var tm = editor.InstallTextMate(registryOptions); + tm.SetGrammar(registryOptions.GetScopeByLanguageId("sql")); + } + + // Context menu for read-only schema tabs + var schemaCopy = new MenuItem { Header = "Copy" }; + schemaCopy.Click += async (_, _) => + { + var clipboard = TopLevel.GetTopLevel(this)?.Clipboard; + if (clipboard == null) return; + var sel = editor.TextArea.Selection; + if (!sel.IsEmpty) + await clipboard.SetTextAsync(sel.GetText()); + }; + var schemaCopyAll = new MenuItem { Header = "Copy All" }; + schemaCopyAll.Click += async (_, _) => + { + var clipboard = TopLevel.GetTopLevel(this)?.Clipboard; + if (clipboard == null) return; + await clipboard.SetTextAsync(editor.Text); + }; + var schemaSelectAll = new MenuItem { Header = "Select All" }; + schemaSelectAll.Click += (_, _) => editor.SelectAll(); + editor.TextArea.ContextMenu = new ContextMenu + { + Items = { schemaCopy, schemaCopyAll, new Separator(), schemaSelectAll } + }; + + var headerText = new TextBlock + { + Text = label, + VerticalAlignment = VerticalAlignment.Center, + FontSize = 12 + }; + + var closeBtn = new Button + { + Content = "\u2715", + MinWidth = 22, MinHeight = 22, Width = 22, Height = 22, + Padding = new Avalonia.Thickness(0), + FontSize = 11, + Margin = new Avalonia.Thickness(6, 0, 0, 0), + Background = Brushes.Transparent, + BorderThickness = new Avalonia.Thickness(0), + Foreground = new SolidColorBrush(Color.FromRgb(0xE4, 0xE6, 0xEB)), + VerticalAlignment = VerticalAlignment.Center, + HorizontalContentAlignment = HorizontalAlignment.Center, + VerticalContentAlignment = VerticalAlignment.Center + }; + + var header = new StackPanel + { + Orientation = Orientation.Horizontal, + Children = { headerText, closeBtn } + }; + + var tab = new TabItem { Header = header, Content = editor }; + closeBtn.Tag = tab; + closeBtn.Click += (s, _) => + { + if (s is Button btn && btn.Tag is TabItem t) + SubTabControl.Items.Remove(t); + }; + + SubTabControl.Items.Add(tab); + 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.App/Controls/QuerySessionControl.axaml.cs b/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs index 5d6080a..3c5f290 100644 --- a/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs +++ b/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs @@ -101,12 +101,6 @@ public QuerySessionControl(ICredentialService credentialService, ConnectionStore }; } - private void SetupSyntaxHighlighting() - { - var registryOptions = new RegistryOptions(ThemeName.DarkPlus); - _textMateInstallation = QueryEditor.InstallTextMate(registryOptions); - _textMateInstallation.SetGrammar(registryOptions.GetScopeByLanguageId("sql")); - } // Schema context menu items — stored as fields so we can toggle visibility on menu open private MenuItem? _showIndexesItem; @@ -115,603 +109,9 @@ private void SetupSyntaxHighlighting() private Separator? _schemaSeparator; private ResolvedSqlObject? _contextMenuObject; - private void SetupEditorContextMenu() - { - var cutItem = new MenuItem { Header = "Cut" }; - cutItem.Click += async (_, _) => - { - var clipboard = TopLevel.GetTopLevel(this)?.Clipboard; - if (clipboard == null) return; - var selection = QueryEditor.TextArea.Selection; - if (selection.IsEmpty) return; - var text = selection.GetText(); - await clipboard.SetTextAsync(text); - selection.ReplaceSelectionWithText(""); - }; - - var copyItem = new MenuItem { Header = "Copy" }; - copyItem.Click += async (_, _) => - { - var clipboard = TopLevel.GetTopLevel(this)?.Clipboard; - if (clipboard == null) return; - var selection = QueryEditor.TextArea.Selection; - if (selection.IsEmpty) return; - await clipboard.SetTextAsync(selection.GetText()); - }; - - var pasteItem = new MenuItem { Header = "Paste" }; - pasteItem.Click += async (_, _) => - { - var clipboard = TopLevel.GetTopLevel(this)?.Clipboard; - if (clipboard == null) return; - var text = await clipboard.TryGetTextAsync(); - if (string.IsNullOrEmpty(text)) return; - QueryEditor.TextArea.PerformTextInput(text); - }; - - var selectAllItem = new MenuItem { Header = "Select All" }; - selectAllItem.Click += (_, _) => - { - QueryEditor.SelectAll(); - }; - - var executeFromCursorItem = new MenuItem { Header = "Execute from Cursor" }; - executeFromCursorItem.Click += async (_, _) => - { - var text = GetTextFromCursor(); - if (!string.IsNullOrWhiteSpace(text)) - await CaptureAndShowPlan(estimated: false, queryTextOverride: text); - }; - - var executeCurrentBatchItem = new MenuItem { Header = "Execute Current Batch" }; - executeCurrentBatchItem.Click += async (_, _) => - { - var text = GetCurrentBatch(); - if (!string.IsNullOrWhiteSpace(text)) - await CaptureAndShowPlan(estimated: false, queryTextOverride: text); - }; - - // Schema lookup items - _schemaSeparator = new Separator(); - - _showIndexesItem = new MenuItem { Header = "Show Indexes" }; - _showIndexesItem.Click += async (_, _) => await ShowSchemaInfoAsync(SchemaInfoKind.Indexes); - - _showTableDefItem = new MenuItem { Header = "Show Table Definition" }; - _showTableDefItem.Click += async (_, _) => await ShowSchemaInfoAsync(SchemaInfoKind.TableDefinition); - - _showObjectDefItem = new MenuItem { Header = "Show Object Definition" }; - _showObjectDefItem.Click += async (_, _) => await ShowSchemaInfoAsync(SchemaInfoKind.ObjectDefinition); - - var contextMenu = new ContextMenu - { - Items = - { - cutItem, copyItem, pasteItem, - new Separator(), selectAllItem, - new Separator(), executeFromCursorItem, executeCurrentBatchItem, - _schemaSeparator, - _showIndexesItem, _showTableDefItem, _showObjectDefItem - } - }; - - contextMenu.Opening += OnContextMenuOpening; - QueryEditor.TextArea.ContextMenu = contextMenu; - - // Move caret to right-click position so schema lookup resolves the clicked object - QueryEditor.TextArea.PointerPressed += OnEditorPointerPressed; - } - - private void OnEditorPointerPressed(object? sender, Avalonia.Input.PointerPressedEventArgs e) - { - if (!e.GetCurrentPoint(QueryEditor.TextArea).Properties.IsRightButtonPressed) - return; - - var pos = QueryEditor.GetPositionFromPoint(e.GetPosition(QueryEditor)); - if (pos == null) return; - - QueryEditor.TextArea.Caret.Position = pos.Value; - } - - private void OnContextMenuOpening(object? sender, System.ComponentModel.CancelEventArgs e) - { - // Resolve what object is under the cursor - var sqlText = QueryEditor.Text; - var offset = QueryEditor.CaretOffset; - _contextMenuObject = SqlObjectResolver.Resolve(sqlText, offset); - - var hasConnection = _connectionString != null; - var hasObject = _contextMenuObject != null && hasConnection; - - _schemaSeparator!.IsVisible = hasObject; - _showIndexesItem!.IsVisible = hasObject && _contextMenuObject!.Kind is SqlObjectKind.Table or SqlObjectKind.Unknown; - _showTableDefItem!.IsVisible = hasObject && _contextMenuObject!.Kind is SqlObjectKind.Table or SqlObjectKind.Unknown; - _showObjectDefItem!.IsVisible = hasObject && _contextMenuObject!.Kind is SqlObjectKind.Function or SqlObjectKind.Procedure; - - // Update headers to show the object name - if (hasObject) - { - var name = _contextMenuObject!.FullName; - _showIndexesItem.Header = $"Show Indexes — {name}"; - _showTableDefItem.Header = $"Show Table Definition — {name}"; - _showObjectDefItem.Header = $"Show Object Definition — {name}"; - } - } private enum SchemaInfoKind { Indexes, TableDefinition, ObjectDefinition } - private async Task ShowSchemaInfoAsync(SchemaInfoKind kind) - { - if (_contextMenuObject == null || _connectionString == null) return; - - var objectName = _contextMenuObject.FullName; - SetStatus($"Fetching {kind} for {objectName}...", autoClear: false); - - try - { - string content; - string tabLabel; - - switch (kind) - { - case SchemaInfoKind.Indexes: - var indexes = await SchemaQueryService.FetchIndexesAsync(_connectionString, objectName); - content = 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); - tabLabel = $"Table — {objectName}"; - break; - - case SchemaInfoKind.ObjectDefinition: - var definition = await SchemaQueryService.FetchObjectDefinitionAsync(_connectionString, objectName); - content = definition ?? $"-- No definition found for {objectName}"; - tabLabel = $"Definition — {objectName}"; - break; - - default: - return; - } - - AddSchemaTab(tabLabel, content, isSql: true); - SetStatus($"Loaded {kind} for {objectName}"); - } - catch (Exception ex) - { - SetStatus($"Error: {ex.Message}", autoClear: false); - Debug.WriteLine($"Schema lookup error: {ex}"); - } - } - - private void AddSchemaTab(string label, string content, bool isSql) - { - var editor = new TextEditor - { - Text = content, - IsReadOnly = true, - FontFamily = new FontFamily("Consolas, Menlo, monospace"), - FontSize = 13, - ShowLineNumbers = true, - Background = (IBrush)this.FindResource("BackgroundBrush")!, - Foreground = (IBrush)this.FindResource("ForegroundBrush")!, - HorizontalScrollBarVisibility = Avalonia.Controls.Primitives.ScrollBarVisibility.Auto, - VerticalScrollBarVisibility = Avalonia.Controls.Primitives.ScrollBarVisibility.Auto, - Padding = new Avalonia.Thickness(4) - }; - - if (isSql) - { - var registryOptions = new RegistryOptions(ThemeName.DarkPlus); - var tm = editor.InstallTextMate(registryOptions); - tm.SetGrammar(registryOptions.GetScopeByLanguageId("sql")); - } - - // Context menu for read-only schema tabs - var schemaCopy = new MenuItem { Header = "Copy" }; - schemaCopy.Click += async (_, _) => - { - var clipboard = TopLevel.GetTopLevel(this)?.Clipboard; - if (clipboard == null) return; - var sel = editor.TextArea.Selection; - if (!sel.IsEmpty) - await clipboard.SetTextAsync(sel.GetText()); - }; - var schemaCopyAll = new MenuItem { Header = "Copy All" }; - schemaCopyAll.Click += async (_, _) => - { - var clipboard = TopLevel.GetTopLevel(this)?.Clipboard; - if (clipboard == null) return; - await clipboard.SetTextAsync(editor.Text); - }; - var schemaSelectAll = new MenuItem { Header = "Select All" }; - schemaSelectAll.Click += (_, _) => editor.SelectAll(); - editor.TextArea.ContextMenu = new ContextMenu - { - Items = { schemaCopy, schemaCopyAll, new Separator(), schemaSelectAll } - }; - - var headerText = new TextBlock - { - Text = label, - VerticalAlignment = VerticalAlignment.Center, - FontSize = 12 - }; - - var closeBtn = new Button - { - Content = "\u2715", - MinWidth = 22, MinHeight = 22, Width = 22, Height = 22, - Padding = new Avalonia.Thickness(0), - FontSize = 11, - Margin = new Avalonia.Thickness(6, 0, 0, 0), - Background = Brushes.Transparent, - BorderThickness = new Avalonia.Thickness(0), - Foreground = new SolidColorBrush(Color.FromRgb(0xE4, 0xE6, 0xEB)), - VerticalAlignment = VerticalAlignment.Center, - HorizontalContentAlignment = HorizontalAlignment.Center, - VerticalContentAlignment = VerticalAlignment.Center - }; - - var header = new StackPanel - { - Orientation = Orientation.Horizontal, - Children = { headerText, closeBtn } - }; - - var tab = new TabItem { Header = header, Content = editor }; - closeBtn.Tag = tab; - closeBtn.Click += (s, _) => - { - if (s is Button btn && btn.Tag is TabItem t) - SubTabControl.Items.Remove(t); - }; - - SubTabControl.Items.Add(tab); - 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}]"; - } - - private void OnOpenInEditorRequested(object? sender, string queryText) - { - QueryEditor.Text = queryText; - SubTabControl.SelectedIndex = 0; // Switch to the editor tab - QueryEditor.Focus(); - } - - private void OnKeyDown(object? sender, KeyEventArgs e) - { - // F5 or Ctrl+E → Execute (actual plan) - if ((e.Key == Key.F5 || (e.Key == Key.E && e.KeyModifiers == KeyModifiers.Control)) - && ExecuteButton.IsEnabled) - { - Execute_Click(this, new RoutedEventArgs()); - e.Handled = true; - } - // Ctrl+L → Estimated plan - else if (e.Key == Key.L && e.KeyModifiers == KeyModifiers.Control - && ExecuteEstButton.IsEnabled) - { - ExecuteEstimated_Click(this, new RoutedEventArgs()); - e.Handled = true; - } - // Escape → Cancel running query - else if (e.Key == Key.Escape && _executionCts != null && !_executionCts.IsCancellationRequested) - { - _executionCts.Cancel(); - e.Handled = true; - } - } - - private void OnEditorPointerWheel(object? sender, PointerWheelEventArgs e) - { - if (e.KeyModifiers != KeyModifiers.Control) return; - - var delta = e.Delta.Y > 0 ? 1 : -1; - var newSize = QueryEditor.FontSize + delta; - QueryEditor.FontSize = Math.Clamp(newSize, 7, 52); - SyncZoomDropdown(); - e.Handled = true; - } - - private void Zoom_SelectionChanged(object? sender, SelectionChangedEventArgs e) - { - if (ZoomBox.SelectedItem is ComboBoxItem item && item.Tag is string tagStr - && int.TryParse(tagStr, out var size)) - { - QueryEditor.FontSize = size; - } - } - - private void SyncZoomDropdown() - { - // Find the closest matching zoom level - var fontSize = (int)Math.Round(QueryEditor.FontSize); - int bestIdx = 2; // default 100% - int bestDist = int.MaxValue; - - for (int i = 0; i < ZoomBox.Items.Count; i++) - { - if (ZoomBox.Items[i] is ComboBoxItem item && item.Tag is string tagStr - && int.TryParse(tagStr, out var size)) - { - var dist = Math.Abs(size - fontSize); - if (dist < bestDist) { bestDist = dist; bestIdx = i; } - } - } - - ZoomBox.SelectionChanged -= Zoom_SelectionChanged; - ZoomBox.SelectedIndex = bestIdx; - ZoomBox.SelectionChanged += Zoom_SelectionChanged; - } - - private void OnTextEntering(object? sender, TextInputEventArgs e) - { - if (_completionWindow == null || string.IsNullOrEmpty(e.Text)) return; - - // If the user types a non-identifier character, let the completion window - // decide whether to commit (it handles Tab/Enter/Space automatically) - var ch = e.Text[0]; - if (!char.IsLetterOrDigit(ch) && ch != '_') - { - _completionWindow.CompletionList.RequestInsertion(e); - } - } - - private void OnTextEntered(object? sender, TextInputEventArgs e) - { - if (_completionWindow != null) return; - if (string.IsNullOrEmpty(e.Text) || !char.IsLetter(e.Text[0])) return; - - var (prefix, wordStart) = GetWordBeforeCaret(); - if (prefix.Length < 2) return; - - var matches = SqlKeywords.All - .Where(k => k.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) - .ToArray(); - - if (matches.Length == 0) return; - - _completionWindow = new CompletionWindow(QueryEditor.TextArea); - _completionWindow.StartOffset = wordStart; - _completionWindow.Closed += (_, _) => _completionWindow = null; - - foreach (var kw in matches) - _completionWindow.CompletionList.CompletionData.Add(new SqlCompletionData(kw)); - - _completionWindow.Show(); - } private (string prefix, int startOffset) GetWordBeforeCaret() { @@ -731,1521 +131,47 @@ private void OnTextEntered(object? sender, TextInputEventArgs e) return (doc.GetText(start, offset - start), start); } - private string? GetSelectedTextOrNull() - { - var selection = QueryEditor.TextArea.Selection; - if (selection.IsEmpty) return null; - return selection.GetText(); - } - private string GetTextFromCursor() - { - var doc = QueryEditor.Document; - var offset = QueryEditor.CaretOffset; - return doc.GetText(offset, doc.TextLength - offset); - } + private bool IsAzureConnection => + _serverConnection != null && + (_serverConnection.ServerName.Contains(".database.windows.net", StringComparison.OrdinalIgnoreCase) || + _serverConnection.ServerName.Contains(".database.azure.com", StringComparison.OrdinalIgnoreCase)); - private string? GetCurrentBatch() - { - var doc = QueryEditor.Document; - var caretOffset = QueryEditor.CaretOffset; - var text = doc.Text; - var goPattern = new Regex(@"^\s*GO\s*$", RegexOptions.IgnoreCase | RegexOptions.Multiline); - var matches = goPattern.Matches(text); - int batchStart = 0; - int batchEnd = text.Length; + private (AnalysisResult? Analysis, PlanViewerControl? Viewer) GetCurrentAnalysisWithViewer() + { + // Find the currently selected plan tab's PlanViewerControl + if (SubTabControl.SelectedItem is TabItem tab && tab.Content is PlanViewerControl viewer + && viewer.CurrentPlan != null) + { + return (ResultMapper.Map(viewer.CurrentPlan, "query editor", _serverMetadata), viewer); + } - foreach (Match m in matches) + // Fallback: find the most recent plan tab + for (int i = SubTabControl.Items.Count - 1; i >= 0; i--) { - if (m.Index + m.Length <= caretOffset) - { - batchStart = m.Index + m.Length; - } - else if (m.Index >= caretOffset) + if (SubTabControl.Items[i] is TabItem planTab && planTab.Content is PlanViewerControl v + && v.CurrentPlan != null) { - batchEnd = m.Index; - break; + return (ResultMapper.Map(v.CurrentPlan, "query editor"), v); } } - return text[batchStart..batchEnd].Trim(); + return (null, null); } - private void SetStatus(string text, bool autoClear = true) + public IEnumerable<(string label, PlanViewerControl viewer)> GetPlanTabs() { - var old = _statusClearCts; - _statusClearCts = null; - old?.Cancel(); - old?.Dispose(); - - StatusText.Text = text; - - if (autoClear && !string.IsNullOrEmpty(text)) + foreach (var item in SubTabControl.Items) { - var cts = new CancellationTokenSource(); - _statusClearCts = cts; - _ = Task.Delay(3000, cts.Token).ContinueWith(_ => + if (item is TabItem tab && tab.Content is PlanViewerControl viewer + && viewer.CurrentPlan != null) { - Avalonia.Threading.Dispatcher.UIThread.Post(() => StatusText.Text = ""); - }, TaskContinuationOptions.OnlyOnRanToCompletion); + yield return (GetTabLabel(tab), viewer); + } } } - private async void Connect_Click(object? sender, RoutedEventArgs e) - { - await ShowConnectionDialogAsync(); - } - - private async Task ShowConnectionDialogAsync() - { - var dialog = new ConnectionDialog(_credentialService, _connectionStore); - var result = await dialog.ShowDialog(GetParentWindow()); - - if (result == true && dialog.ResultConnection != null) - { - _serverConnection = dialog.ResultConnection; - _selectedDatabase = dialog.ResultDatabase; - _connectionString = _serverConnection.GetConnectionString(_credentialService, _selectedDatabase); - - ServerLabel.Text = _serverConnection.ApplicationIntentReadOnly - ? $"{_serverConnection.ServerName} (Read-only)" - : _serverConnection.ServerName; - ServerLabel.Foreground = Brushes.LimeGreen; - ConnectButton.Content = "Reconnect"; - - await PopulateDatabases(); - await FetchServerMetadataAsync(); - await FetchServerUtcOffset(); - - if (_selectedDatabase != null) - { - for (int i = 0; i < DatabaseBox.Items.Count; i++) - { - if (DatabaseBox.Items[i]?.ToString() == _selectedDatabase) - { - DatabaseBox.SelectedIndex = i; - break; - } - } - } - - await FetchDatabaseMetadataAsync(); - ExecuteButton.IsEnabled = true; - ExecuteEstButton.IsEnabled = true; - } - } - - private async Task PopulateDatabases() - { - if (_serverConnection == null) return; - - try - { - var connStr = _serverConnection.GetConnectionString(_credentialService, "master"); - await using var conn = new SqlConnection(connStr); - await conn.OpenAsync(); - - var databases = new List(); - using var cmd = new SqlCommand( - "SELECT name FROM sys.databases WHERE state_desc = 'ONLINE' ORDER BY name", conn); - using var reader = await cmd.ExecuteReaderAsync(); - while (await reader.ReadAsync()) - databases.Add(reader.GetString(0)); - - DatabaseBox.ItemsSource = databases; - DatabaseBox.IsEnabled = true; - } - catch - { - DatabaseBox.IsEnabled = false; - } - } - - private async void Database_SelectionChanged(object? sender, SelectionChangedEventArgs e) - { - if (_serverConnection == null || DatabaseBox.SelectedItem == null) return; - - _selectedDatabase = DatabaseBox.SelectedItem.ToString(); - _connectionString = _serverConnection.GetConnectionString(_credentialService, _selectedDatabase); - - // Refresh database metadata for the new context - await FetchDatabaseMetadataAsync(); - } - - private bool IsAzureConnection => - _serverConnection != null && - (_serverConnection.ServerName.Contains(".database.windows.net", StringComparison.OrdinalIgnoreCase) || - _serverConnection.ServerName.Contains(".database.azure.com", StringComparison.OrdinalIgnoreCase)); - - private async Task FetchServerMetadataAsync() - { - if (_connectionString == null) return; - try - { - _serverMetadata = await ServerMetadataService.FetchServerMetadataAsync( - _connectionString, IsAzureConnection); - } - catch - { - // Non-fatal — advice will just lack server context - _serverMetadata = null; - } - } - - private async Task FetchServerUtcOffset() - { - if (_connectionString == null) return; - try - { - await using var conn = new SqlConnection(_connectionString); - await conn.OpenAsync(); - await using var cmd = new SqlCommand( - "SELECT DATEDIFF(MINUTE, GETUTCDATE(), GETDATE())", conn); - var offset = await cmd.ExecuteScalarAsync(); - if (offset is int mins) - PlanViewer.Core.Services.TimeDisplayHelper.ServerUtcOffsetMinutes = mins; - } - catch { } - } - - private async Task FetchDatabaseMetadataAsync() - { - if (_connectionString == null || _serverMetadata == null) return; - try - { - _serverMetadata.Database = await ServerMetadataService.FetchDatabaseMetadataAsync( - _connectionString, _serverMetadata.SupportsScopedConfigs); - } - catch - { - // Non-fatal — advice will just lack database context - } - } - - private async void Execute_Click(object? sender, RoutedEventArgs e) - { - await CaptureAndShowPlan(estimated: false); - } - - private async void ExecuteEstimated_Click(object? sender, RoutedEventArgs e) - { - await CaptureAndShowPlan(estimated: true); - } - - private async Task CaptureAndShowPlan(bool estimated, string? queryTextOverride = null) - { - if (_serverConnection == null || _selectedDatabase == null) - { - SetStatus("Connect to a server first", autoClear: false); - return; - } - - // Always rebuild connection string from current database selection - // to guarantee the picker state is reflected at execution time - _connectionString = _serverConnection.GetConnectionString(_credentialService, _selectedDatabase); - - var queryText = queryTextOverride?.Trim() - ?? GetSelectedTextOrNull()?.Trim() - ?? QueryEditor.Text?.Trim(); - if (string.IsNullOrEmpty(queryText)) - { - SetStatus("Enter a query", autoClear: false); - return; - } - - _executionCts?.Cancel(); - _executionCts?.Dispose(); - _executionCts = new CancellationTokenSource(); - var ct = _executionCts.Token; - - var planType = estimated ? "Estimated" : "Actual"; - - // Create loading tab with cancel button - var loadingPanel = new StackPanel - { - VerticalAlignment = VerticalAlignment.Center, - HorizontalAlignment = HorizontalAlignment.Center, - Width = 300 - }; - - var progressBar = new ProgressBar - { - IsIndeterminate = true, - Height = 4, - Margin = new Avalonia.Thickness(0, 0, 0, 12) - }; - - var statusLabel = new TextBlock - { - Text = $"Capturing {planType.ToLower()} plan...", - FontSize = 14, - Foreground = new SolidColorBrush(Color.Parse("#E4E6EB")), - HorizontalAlignment = HorizontalAlignment.Center - }; - - var cancelBtn = new Button - { - Content = "\u25A0 Cancel", - Height = 32, - Width = 120, - Padding = new Avalonia.Thickness(16, 0), - FontSize = 13, - Margin = new Avalonia.Thickness(0, 16, 0, 0), - HorizontalAlignment = HorizontalAlignment.Center, - HorizontalContentAlignment = HorizontalAlignment.Center, - VerticalContentAlignment = VerticalAlignment.Center, - Theme = (Avalonia.Styling.ControlTheme)this.FindResource("AppButton")! - }; - cancelBtn.Click += (_, _) => _executionCts?.Cancel(); - - loadingPanel.Children.Add(progressBar); - loadingPanel.Children.Add(statusLabel); - loadingPanel.Children.Add(cancelBtn); - - var loadingContainer = new Grid - { - Background = new SolidColorBrush(Color.Parse("#1A1D23")), - Focusable = true, - Children = { loadingPanel } - }; - loadingContainer.KeyDown += (_, ke) => - { - if (ke.Key == Key.Escape) { _executionCts?.Cancel(); ke.Handled = true; } - }; - - // Add loading tab and switch to it - _planCounter++; - var tabLabel = estimated ? $"Est Plan {_planCounter}" : $"Plan {_planCounter}"; - var headerText = new TextBlock - { - Text = tabLabel, - VerticalAlignment = VerticalAlignment.Center, - FontSize = 12 - }; - var closeBtn = new Button - { - Content = "\u2715", - MinWidth = 22, MinHeight = 22, Width = 22, Height = 22, - Padding = new Avalonia.Thickness(0), - FontSize = 11, - Margin = new Avalonia.Thickness(6, 0, 0, 0), - Background = Brushes.Transparent, - BorderThickness = new Avalonia.Thickness(0), - Foreground = new SolidColorBrush(Color.FromRgb(0xE4, 0xE6, 0xEB)), - VerticalAlignment = VerticalAlignment.Center, - HorizontalContentAlignment = HorizontalAlignment.Center, - VerticalContentAlignment = VerticalAlignment.Center - }; - var header = new StackPanel - { - Orientation = Orientation.Horizontal, - Children = { headerText, closeBtn } - }; - var loadingTab = new TabItem { Header = header, Content = loadingContainer }; - closeBtn.Tag = loadingTab; - closeBtn.Click += ClosePlanTab_Click; - - SubTabControl.Items.Add(loadingTab); - SubTabControl.SelectedItem = loadingTab; - loadingContainer.Focus(); - - try - { - var sw = Stopwatch.StartNew(); - string? planXml; - - var isAzure = _serverConnection!.ServerName.Contains(".database.windows.net", - StringComparison.OrdinalIgnoreCase) || - _serverConnection.ServerName.Contains(".database.azure.com", - StringComparison.OrdinalIgnoreCase); - - if (estimated) - { - planXml = await EstimatedPlanExecutor.GetEstimatedPlanAsync( - _connectionString, _selectedDatabase, queryText, timeoutSeconds: 0, ct); - } - else - { - planXml = await ActualPlanExecutor.ExecuteForActualPlanAsync( - _connectionString, _selectedDatabase, queryText, - planXml: null, isolationLevel: null, - isAzureSqlDb: isAzure, timeoutSeconds: 0, ct); - } - - sw.Stop(); - - if (string.IsNullOrEmpty(planXml)) - { - statusLabel.Text = $"No plan returned ({sw.Elapsed.TotalSeconds:F1}s)"; - progressBar.IsVisible = false; - cancelBtn.IsVisible = false; - return; - } - - // Replace loading content with the plan viewer - SetStatus($"{planType} plan captured ({sw.Elapsed.TotalSeconds:F1}s)"); - var viewer = new PlanViewerControl(); - viewer.Metadata = _serverMetadata; - viewer.ConnectionString = _connectionString; - viewer.SetConnectionServices(_credentialService, _connectionStore); - if (_serverConnection != null) - viewer.SetConnectionStatus(_serverConnection.ServerName, _selectedDatabase); - viewer.OpenInEditorRequested += OnOpenInEditorRequested; - viewer.LoadPlan(planXml, tabLabel, queryText); - loadingTab.Content = viewer; - HumanAdviceButton.IsEnabled = true; - RobotAdviceButton.IsEnabled = true; - } - catch (OperationCanceledException) - { - SetStatus("Cancelled"); - SubTabControl.Items.Remove(loadingTab); - } - catch (SqlException ex) - { - statusLabel.Text = ex.Message.Length > 100 ? ex.Message[..100] + "..." : ex.Message; - progressBar.IsVisible = false; - cancelBtn.IsVisible = false; - } - catch (Exception ex) - { - statusLabel.Text = ex.Message.Length > 100 ? ex.Message[..100] + "..." : ex.Message; - progressBar.IsVisible = false; - cancelBtn.IsVisible = false; - } - } - - private AnalysisResult? GetCurrentAnalysis() - { - return GetCurrentAnalysisWithViewer().Analysis; - } - - private (AnalysisResult? Analysis, PlanViewerControl? Viewer) GetCurrentAnalysisWithViewer() - { - // Find the currently selected plan tab's PlanViewerControl - if (SubTabControl.SelectedItem is TabItem tab && tab.Content is PlanViewerControl viewer - && viewer.CurrentPlan != null) - { - return (ResultMapper.Map(viewer.CurrentPlan, "query editor", _serverMetadata), viewer); - } - - // Fallback: find the most recent plan tab - for (int i = SubTabControl.Items.Count - 1; i >= 0; i--) - { - if (SubTabControl.Items[i] is TabItem planTab && planTab.Content is PlanViewerControl v - && v.CurrentPlan != null) - { - return (ResultMapper.Map(v.CurrentPlan, "query editor"), v); - } - } - - return (null, null); - } - - private void HumanAdvice_Click(object? sender, RoutedEventArgs e) - { - var (analysis, viewer) = GetCurrentAnalysisWithViewer(); - if (analysis == null) { SetStatus("No plan to analyze", autoClear: false); return; } - - var text = TextFormatter.Format(analysis); - ShowAdviceWindow("Advice for Humans", text, analysis, viewer); - } - - private void RobotAdvice_Click(object? sender, RoutedEventArgs e) - { - var analysis = GetCurrentAnalysis(); - if (analysis == null) { SetStatus("No plan to analyze", autoClear: false); return; } - - var json = JsonSerializer.Serialize(analysis, new JsonSerializerOptions { WriteIndented = true }); - ShowAdviceWindow("Advice for Robots", json); - } - - private void ShowAdviceWindow(string title, string content, AnalysisResult? analysis = null, PlanViewerControl? sourceViewer = null) - { - AdviceWindowHelper.Show(GetParentWindow(), title, content, analysis, sourceViewer); - } - - private void AddPlanTab(string planXml, string queryText, bool estimated, string? labelOverride = null) - { - _planCounter++; - var label = labelOverride ?? (estimated ? $"Est Plan {_planCounter}" : $"Plan {_planCounter}"); - - var viewer = new PlanViewerControl(); - viewer.Metadata = _serverMetadata; - viewer.ConnectionString = _connectionString; - viewer.SetConnectionServices(_credentialService, _connectionStore); - if (_serverConnection != null) - viewer.SetConnectionStatus(_serverConnection.ServerName, _selectedDatabase); - viewer.OpenInEditorRequested += OnOpenInEditorRequested; - viewer.LoadPlan(planXml, label, queryText); - - // Build tab header with close button and right-click rename - var headerText = new TextBlock - { - Text = label, - VerticalAlignment = VerticalAlignment.Center, - FontSize = 12 - }; - - var closeBtn = new Button - { - Content = "\u2715", - MinWidth = 22, - MinHeight = 22, - Width = 22, - Height = 22, - Padding = new Avalonia.Thickness(0), - FontSize = 11, - Margin = new Avalonia.Thickness(6, 0, 0, 0), - Background = Brushes.Transparent, - BorderThickness = new Avalonia.Thickness(0), - Foreground = new SolidColorBrush(Color.FromRgb(0xE4, 0xE6, 0xEB)), - VerticalAlignment = VerticalAlignment.Center, - HorizontalContentAlignment = HorizontalAlignment.Center, - VerticalContentAlignment = VerticalAlignment.Center - }; - - var header = new StackPanel - { - Orientation = Orientation.Horizontal, - Children = { headerText, closeBtn } - }; - - var tab = new TabItem { Header = header, Content = viewer }; - closeBtn.Tag = tab; - closeBtn.Click += ClosePlanTab_Click; - - // Right-click context menu - var contextMenu = new ContextMenu - { - Items = - { - new MenuItem { Header = "Rename Tab", Tag = new object[] { header, headerText } }, - new Separator(), - new MenuItem { Header = "Close", Tag = tab, InputGesture = new KeyGesture(Key.W, KeyModifiers.Control) }, - new MenuItem { Header = "Close Other Tabs", Tag = tab }, - new MenuItem { Header = "Close All Tabs" } - } - }; - - foreach (var item in contextMenu.Items.OfType()) - item.Click += PlanTabContextMenu_Click; - - header.ContextMenu = contextMenu; - - SubTabControl.Items.Add(tab); - SubTabControl.SelectedItem = tab; - UpdateCompareButtonState(); - } - - private void StartRename(StackPanel header, TextBlock headerText) - { - var textBox = new TextBox - { - Text = headerText.Text, - FontSize = 12, - MinWidth = 80, - Padding = new Avalonia.Thickness(2, 0), - VerticalAlignment = VerticalAlignment.Center - }; - - headerText.IsVisible = false; - header.Children.Insert(0, textBox); - textBox.Focus(); - textBox.SelectAll(); - - void CommitRename() - { - var newName = textBox.Text?.Trim(); - if (!string.IsNullOrEmpty(newName)) - headerText.Text = newName; - - headerText.IsVisible = true; - header.Children.Remove(textBox); - } - - textBox.KeyDown += (_, ke) => - { - if (ke.Key == Key.Enter || ke.Key == Key.Escape) - { - if (ke.Key == Key.Escape) - textBox.Text = headerText.Text; - CommitRename(); - ke.Handled = true; - } - }; - - textBox.LostFocus += (_, _) => CommitRename(); - } - - private void ClosePlanTab_Click(object? sender, RoutedEventArgs e) - { - if (sender is Button btn && btn.Tag is TabItem tab) - { - if (tab.Content is PlanViewerControl viewer) - viewer.Clear(); - SubTabControl.Items.Remove(tab); - UpdateCompareButtonState(); - } - } - - private void PlanTabContextMenu_Click(object? sender, RoutedEventArgs e) - { - if (sender is not MenuItem item) return; - - switch (item.Header?.ToString()) - { - case "Rename Tab": - if (item.Tag is object[] parts) - StartRename((StackPanel)parts[0], (TextBlock)parts[1]); - break; - - case "Close": - if (item.Tag is TabItem tab) - { - if (tab.Content is PlanViewerControl closeViewer) - closeViewer.Clear(); - SubTabControl.Items.Remove(tab); - UpdateCompareButtonState(); - } - break; - - case "Close Other Tabs": - if (item.Tag is TabItem keepTab) - { - // Keep the Editor tab (index 0) and the selected tab - var others = SubTabControl.Items.Cast() - .OfType() - .Where(t => t != keepTab && t.Content is PlanViewerControl) - .ToList(); - foreach (var t in others) - { - if (t.Content is PlanViewerControl otherViewer) - otherViewer.Clear(); - SubTabControl.Items.Remove(t); - } - SubTabControl.SelectedItem = keepTab; - UpdateCompareButtonState(); - } - break; - - case "Close All Tabs": - var planTabs = SubTabControl.Items.Cast() - .OfType() - .Where(t => t.Content is PlanViewerControl) - .ToList(); - foreach (var t in planTabs) - { - if (t.Content is PlanViewerControl allViewer) - allViewer.Clear(); - SubTabControl.Items.Remove(t); - } - SubTabControl.SelectedIndex = 0; // back to Editor - UpdateCompareButtonState(); - break; - } - } - - private void UpdateCompareButtonState() - { - int planCount = 0; - foreach (var item in SubTabControl.Items) - { - if (item is TabItem t && t.Content is PlanViewerControl v && v.CurrentPlan != null) - planCount++; - } - ComparePlansButton.IsEnabled = planCount >= 2; - } - - public IEnumerable<(string label, PlanViewerControl viewer)> GetPlanTabs() - { - foreach (var item in SubTabControl.Items) - { - if (item is TabItem tab && tab.Content is PlanViewerControl viewer - && viewer.CurrentPlan != null) - { - yield return (GetTabLabel(tab), viewer); - } - } - } - - private static string GetTabLabel(TabItem tab) - { - if (tab.Header is StackPanel sp && sp.Children.Count > 0 && sp.Children[0] is TextBlock tb) - return tb.Text ?? "Plan"; - if (tab.Header is string s) - return s; - return "Plan"; - } - - private bool HasQueryStoreTab() - { - return SubTabControl.Items.OfType() - .Any(t => t.Content is QueryStoreGridControl); - } - - public void TriggerQueryStore() => QueryStore_Click(null, new RoutedEventArgs()); - - private async void QueryStoreOverview_Click(object? sender, RoutedEventArgs e) - { - if (_serverConnection == null || _connectionString == null) - { - await ShowConnectionDialogAsync(); - if (_serverConnection == null || _connectionString == null) - return; - } - - SetStatus("Loading Query Store Overview..."); - - var supportsWaitStats = _serverMetadata?.SupportsQueryStoreWaitStats ?? false; - var overview = new QueryStoreOverviewControl(_serverConnection, _credentialService, - supportsWaitStats: supportsWaitStats); - overview.DrillDownRequested += async (_, args) => - { - // Open a single-database Query Store tab directly (no connection dialog) - _selectedDatabase = args.Database; - _connectionString = _serverConnection!.GetConnectionString(_credentialService, args.Database); - await OpenQueryStoreForDatabaseAsync(args.Database, args.StartUtc, args.EndUtc); - }; - - var headerText = new TextBlock - { - Text = "QS Overview", - VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center, - FontSize = 12 - }; - - var closeBtn = new Button - { - Content = "\u2715", - MinWidth = 22, MinHeight = 22, Width = 22, Height = 22, - Padding = new Avalonia.Thickness(0), - FontSize = 11, - Margin = new Avalonia.Thickness(6, 0, 0, 0), - Background = Brushes.Transparent, - BorderThickness = new Avalonia.Thickness(0), - Foreground = new SolidColorBrush(Color.FromRgb(0xE4, 0xE6, 0xEB)), - VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center, - HorizontalContentAlignment = HorizontalAlignment.Center, - VerticalContentAlignment = VerticalAlignment.Center - }; - - var header = new StackPanel - { - Orientation = Avalonia.Layout.Orientation.Horizontal, - Children = { headerText, closeBtn } - }; - - var tab = new TabItem { Header = header, Content = overview }; - closeBtn.Tag = tab; - closeBtn.Click += (s, _) => - { - if (s is Button btn && btn.Tag is TabItem t) - SubTabControl.Items.Remove(t); - }; - - SubTabControl.Items.Add(tab); - SubTabControl.SelectedItem = tab; - - try - { - await overview.LoadAsync(); - SetStatus(""); - } - catch (Exception ex) - { - SetStatus(ex.Message.Length > 80 ? ex.Message[..80] + "..." : ex.Message, autoClear: false); - } - } - - private async Task OpenQueryStoreForDatabaseAsync(string database, DateTime? initialStartUtc = null, DateTime? initialEndUtc = null) - { - var connStr = _serverConnection!.GetConnectionString(_credentialService, database); - - // Check if Query Store is enabled - SetStatus($"Checking Query Store on {database}..."); - try - { - var (enabled, state) = await QueryStoreService.CheckEnabledAsync(connStr); - if (!enabled) - { - SetStatus($"Query Store not enabled on {database} ({state ?? "unknown"})"); - return; - } - } - catch (Exception ex) - { - SetStatus(ex.Message.Length > 80 ? ex.Message[..80] + "..." : ex.Message, autoClear: false); - return; - } - - SetStatus(""); - - // Check if wait stats are supported - var supportsWaitStats = _serverMetadata?.SupportsQueryStoreWaitStats ?? false; - if (supportsWaitStats) - { - try - { - supportsWaitStats = await QueryStoreService.IsWaitStatsCaptureEnabledAsync(connStr); - } - catch { supportsWaitStats = false; } - } - - var databases = DatabaseBox.Items.OfType().ToList(); - - var grid = new QueryStoreGridControl(_serverConnection!, _credentialService, - database, databases, supportsWaitStats); - if (initialStartUtc.HasValue && initialEndUtc.HasValue) - grid.SetInitialTimeRange(initialStartUtc.Value, initialEndUtc.Value); - grid.PlansSelected += OnQueryStorePlansSelected; - - var headerText = new TextBlock - { - Text = $"Query Store — {database}", - VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center, - FontSize = 12 - }; - grid.DatabaseChanged += (_, db) => headerText.Text = $"Query Store — {db}"; - - var closeBtn = new Button - { - Content = "\u2715", - MinWidth = 22, MinHeight = 22, Width = 22, Height = 22, - Padding = new Avalonia.Thickness(0), - FontSize = 11, - Margin = new Avalonia.Thickness(6, 0, 0, 0), - Background = Brushes.Transparent, - BorderThickness = new Avalonia.Thickness(0), - Foreground = new SolidColorBrush(Color.FromRgb(0xE4, 0xE6, 0xEB)), - VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center, - HorizontalContentAlignment = HorizontalAlignment.Center, - VerticalContentAlignment = VerticalAlignment.Center - }; - - var header = new StackPanel - { - Orientation = Avalonia.Layout.Orientation.Horizontal, - Children = { headerText, closeBtn } - }; - - var tab = new TabItem { Header = header, Content = grid }; - closeBtn.Tag = tab; - closeBtn.Click += (s, _) => - { - if (s is Button btn && btn.Tag is TabItem t) - SubTabControl.Items.Remove(t); - }; - - SubTabControl.Items.Add(tab); - SubTabControl.SelectedItem = tab; - } - - private async void QueryStore_Click(object? sender, RoutedEventArgs e) - { - // If a QS tab already exists, always show connection dialog for a fresh tab - if (HasQueryStoreTab() || _connectionString == null || _selectedDatabase == null) - { - await ShowConnectionDialogAsync(); - if (_connectionString == null || _selectedDatabase == null) - return; - } - - // Check if Query Store is enabled - SetStatus("Checking Query Store..."); - try - { - var (enabled, state) = await QueryStoreService.CheckEnabledAsync(_connectionString); - if (!enabled) - { - SetStatus($"Query Store not enabled ({state ?? "unknown"})"); - return; - } - } - catch (Exception ex) - { - SetStatus(ex.Message.Length > 80 ? ex.Message[..80] + "..." : ex.Message, autoClear: false); - return; - } - - SetStatus(""); - - // Check if wait stats are supported (SQL 2017+ / Azure) and capture is enabled - var supportsWaitStats = _serverMetadata?.SupportsQueryStoreWaitStats ?? false; - if (supportsWaitStats) - { - try - { - var connStr = _serverConnection!.GetConnectionString(_credentialService, _selectedDatabase!); - supportsWaitStats = await QueryStoreService.IsWaitStatsCaptureEnabledAsync(connStr); - } - catch - { - supportsWaitStats = false; - } - } - - // Build database list from the current DatabaseBox - var databases = DatabaseBox.Items.OfType().ToList(); - - var grid = new QueryStoreGridControl(_serverConnection!, _credentialService, - _selectedDatabase!, databases, supportsWaitStats); - grid.PlansSelected += OnQueryStorePlansSelected; - - var headerText = new TextBlock - { - Text = $"Query Store — {_selectedDatabase}", - VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center, - FontSize = 12 - }; - - // Update tab header when database is changed via the grid's picker - grid.DatabaseChanged += (_, db) => - { - headerText.Text = $"Query Store — {db}"; - }; - - var closeBtn = new Button - { - Content = "\u2715", - MinWidth = 22, MinHeight = 22, Width = 22, Height = 22, - Padding = new Avalonia.Thickness(0), - FontSize = 11, - Margin = new Avalonia.Thickness(6, 0, 0, 0), - Background = Brushes.Transparent, - BorderThickness = new Avalonia.Thickness(0), - Foreground = new SolidColorBrush(Color.FromRgb(0xE4, 0xE6, 0xEB)), - VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center, - HorizontalContentAlignment = HorizontalAlignment.Center, - VerticalContentAlignment = VerticalAlignment.Center - }; - - var header = new StackPanel - { - Orientation = Avalonia.Layout.Orientation.Horizontal, - Children = { headerText, closeBtn } - }; - - var tab = new TabItem { Header = header, Content = grid }; - closeBtn.Tag = tab; - closeBtn.Click += (s, _) => - { - if (s is Button btn && btn.Tag is TabItem t) - SubTabControl.Items.Remove(t); - }; - - SubTabControl.Items.Add(tab); - SubTabControl.SelectedItem = tab; - } - - private void OnQueryStorePlansSelected(object? sender, List plans) - { - foreach (var qsPlan in plans) - { - var tabLabel = $"QS {qsPlan.QueryId} / {qsPlan.PlanId}"; - AddPlanTab(qsPlan.PlanXml, qsPlan.QueryText, estimated: true, labelOverride: tabLabel); - } - - SetStatus($"{plans.Count} Query Store plans loaded"); - HumanAdviceButton.IsEnabled = true; - RobotAdviceButton.IsEnabled = true; - } - - private void ComparePlans_Click(object? sender, RoutedEventArgs e) - { - var planTabs = GetPlanTabs().ToList(); - if (planTabs.Count < 2) - { - SetStatus("Need at least 2 plan tabs to compare"); - return; - } - - ShowComparePickerDialog(planTabs); - } - - private void ShowComparePickerDialog(List<(string label, PlanViewerControl viewer)> planTabs) - { - var items = planTabs.Select(t => t.label).ToList(); - - var comboA = new ComboBox - { - ItemsSource = items, - SelectedIndex = 0, - Width = 200, - Height = 28, - FontSize = 12, - Margin = new Avalonia.Thickness(8, 0, 0, 0) - }; - - var comboB = new ComboBox - { - ItemsSource = items, - SelectedIndex = items.Count > 1 ? 1 : 0, - Width = 200, - Height = 28, - FontSize = 12, - Margin = new Avalonia.Thickness(8, 0, 0, 0) - }; - - var compareBtn = new Button - { - Content = "Compare", - Height = 32, - Padding = new Avalonia.Thickness(16, 0), - FontSize = 12, - HorizontalContentAlignment = HorizontalAlignment.Center, - VerticalContentAlignment = VerticalAlignment.Center, - Theme = (Avalonia.Styling.ControlTheme)this.FindResource("AppButton")! - }; - - var cancelBtn = new Button - { - Content = "Cancel", - Height = 32, - Padding = new Avalonia.Thickness(16, 0), - FontSize = 12, - Margin = new Avalonia.Thickness(8, 0, 0, 0), - HorizontalContentAlignment = HorizontalAlignment.Center, - VerticalContentAlignment = VerticalAlignment.Center, - Theme = (Avalonia.Styling.ControlTheme)this.FindResource("AppButton")! - }; - - void UpdateCompareEnabled() - { - compareBtn.IsEnabled = comboA.SelectedIndex >= 0 && comboB.SelectedIndex >= 0 - && comboA.SelectedIndex != comboB.SelectedIndex; - } - - comboA.SelectionChanged += (_, _) => UpdateCompareEnabled(); - comboB.SelectionChanged += (_, _) => UpdateCompareEnabled(); - UpdateCompareEnabled(); - - var rowA = new StackPanel - { - Orientation = Orientation.Horizontal, - Margin = new Avalonia.Thickness(0, 0, 0, 8), - Children = - { - new TextBlock { Text = "Plan A:", VerticalAlignment = VerticalAlignment.Center, FontSize = 13, Width = 55 }, - comboA - } - }; - - var rowB = new StackPanel - { - Orientation = Orientation.Horizontal, - Children = - { - new TextBlock { Text = "Plan B:", VerticalAlignment = VerticalAlignment.Center, FontSize = 13, Width = 55 }, - comboB - } - }; - - var buttonPanel = new StackPanel - { - Orientation = Orientation.Horizontal, - HorizontalAlignment = HorizontalAlignment.Right, - Margin = new Avalonia.Thickness(0, 16, 0, 0), - Children = { compareBtn, cancelBtn } - }; - - var content = new StackPanel - { - Margin = new Avalonia.Thickness(20), - Children = - { - new TextBlock { Text = "Select two plans to compare:", FontSize = 14, Margin = new Avalonia.Thickness(0, 0, 0, 12) }, - rowA, - rowB, - buttonPanel - } - }; - - var dialog = new Window - { - Title = "Compare Plans", - Width = 380, - Height = 220, - MinWidth = 380, - MinHeight = 220, - Icon = GetParentWindow().Icon, - Background = new SolidColorBrush(Color.Parse("#1A1D23")), - Foreground = new SolidColorBrush(Color.Parse("#E4E6EB")), - Content = content, - WindowStartupLocation = WindowStartupLocation.CenterOwner - }; - - compareBtn.Click += (_, _) => - { - var idxA = comboA.SelectedIndex; - var idxB = comboB.SelectedIndex; - if (idxA < 0 || idxB < 0 || idxA == idxB) return; - - var (labelA, viewerA) = planTabs[idxA]; - var (labelB, viewerB) = planTabs[idxB]; - - var analysisA = ResultMapper.Map(viewerA.CurrentPlan!, "query editor", _serverMetadata); - var analysisB = ResultMapper.Map(viewerB.CurrentPlan!, "query editor", _serverMetadata); - - var comparison = ComparisonFormatter.Compare(analysisA, analysisB, labelA, labelB); - dialog.Close(); - ShowAdviceWindow("Plan Comparison", comparison); - }; - - cancelBtn.Click += (_, _) => dialog.Close(); - - dialog.ShowDialog(GetParentWindow()); - } - - /// - /// Gets the PlanViewerControl for the currently selected plan tab, or null if - /// the Editor tab or no plan tab is selected. - /// - private PlanViewerControl? GetSelectedPlanViewer() - { - if (SubTabControl.SelectedItem is TabItem tab && tab.Content is PlanViewerControl viewer - && viewer.CurrentPlan != null) - { - return viewer; - } - return null; - } - - /// - /// Enables or disables buttons that require a plan tab to be selected. - /// Called when the SubTabControl selection changes and after plan tabs are added/removed. - /// - private void UpdatePlanTabButtonState() - { - var hasPlanTab = GetSelectedPlanViewer() != null; - var hasConnection = _connectionString != null && _selectedDatabase != null; - - CopyReproButton.IsEnabled = hasPlanTab; - GetActualPlanButton.IsEnabled = hasPlanTab && hasConnection; - - // Advice buttons also depend on a plan being selected - HumanAdviceButton.IsEnabled = hasPlanTab; - RobotAdviceButton.IsEnabled = hasPlanTab; - } - - private async void CopyRepro_Click(object? sender, RoutedEventArgs e) - { - var viewer = GetSelectedPlanViewer(); - if (viewer == null) - { - SetStatus("Select a plan tab first"); - return; - } - - var planXml = viewer.RawXml; - var queryText = viewer.QueryText ?? ""; - - if (string.IsNullOrEmpty(queryText) && string.IsNullOrEmpty(planXml)) - { - SetStatus("No query or plan data available"); - return; - } - - /* Extract database name from plan XML StmtSimple/@DatabaseContext if available, - otherwise fall back to the currently selected database */ - var database = ExtractDatabaseFromPlanXml(planXml) ?? _selectedDatabase; - - var reproScript = ReproScriptBuilder.BuildReproScript( - queryText, - database, - planXml, - isolationLevel: null, - source: "Performance Studio", - isAzureSqlDb: IsAzureConnection); - - try - { - var topLevel = TopLevel.GetTopLevel(this); - if (topLevel?.Clipboard != null) - { - await topLevel.Clipboard.SetTextAsync(reproScript); - SetStatus("Repro script copied to clipboard"); - } - } - catch (Exception ex) - { - SetStatus($"Clipboard error: {ex.Message}"); - } - } - - private async void GetActualPlan_Click(object? sender, RoutedEventArgs e) - { - var viewer = GetSelectedPlanViewer(); - if (viewer == null) - { - SetStatus("Select a plan tab first"); - return; - } - - if (_connectionString == null || _selectedDatabase == null) - { - SetStatus("Connect to a server first", autoClear: false); - return; - } - - var queryText = viewer.QueryText ?? ""; - var planXml = viewer.RawXml; - - if (string.IsNullOrEmpty(queryText)) - { - SetStatus("No query text available for this plan"); - return; - } - - /* Show confirmation dialog */ - var confirmed = await ShowConfirmationDialog( - "Get Actual Plan", - "The query will execute with SET STATISTICS XML ON to capture the actual plan.\n\nAll data results will be discarded.\n\nContinue?"); - - if (!confirmed) return; - - _executionCts?.Cancel(); - _executionCts?.Dispose(); - _executionCts = new CancellationTokenSource(); - var ct = _executionCts.Token; - - // Create loading tab with cancel button - var loadingPanel = new StackPanel - { - VerticalAlignment = VerticalAlignment.Center, - HorizontalAlignment = HorizontalAlignment.Center, - Width = 300 - }; - - var progressBar = new ProgressBar - { - IsIndeterminate = true, - Height = 4, - Margin = new Avalonia.Thickness(0, 0, 0, 12) - }; - - var statusLabel = new TextBlock - { - Text = "Capturing actual plan...", - FontSize = 14, - Foreground = new SolidColorBrush(Color.Parse("#E4E6EB")), - HorizontalAlignment = HorizontalAlignment.Center - }; - - var cancelBtn = new Button - { - Content = "\u25A0 Cancel", - Height = 32, - Width = 120, - Padding = new Avalonia.Thickness(16, 0), - FontSize = 13, - Margin = new Avalonia.Thickness(0, 16, 0, 0), - HorizontalAlignment = HorizontalAlignment.Center, - HorizontalContentAlignment = HorizontalAlignment.Center, - VerticalContentAlignment = VerticalAlignment.Center, - Theme = (Avalonia.Styling.ControlTheme)this.FindResource("AppButton")! - }; - cancelBtn.Click += (_, _) => _executionCts?.Cancel(); - - loadingPanel.Children.Add(progressBar); - loadingPanel.Children.Add(statusLabel); - loadingPanel.Children.Add(cancelBtn); - - var loadingContainer = new Grid - { - Background = new SolidColorBrush(Color.Parse("#1A1D23")), - Focusable = true, - Children = { loadingPanel } - }; - loadingContainer.KeyDown += (_, ke) => - { - if (ke.Key == Key.Escape) { _executionCts?.Cancel(); ke.Handled = true; } - }; - - _planCounter++; - var tabLabel = $"Plan {_planCounter}"; - var headerText = new TextBlock - { - Text = tabLabel, - VerticalAlignment = VerticalAlignment.Center, - FontSize = 12 - }; - var closeBtn = new Button - { - Content = "\u2715", - MinWidth = 22, MinHeight = 22, Width = 22, Height = 22, - Padding = new Avalonia.Thickness(0), - FontSize = 11, - Margin = new Avalonia.Thickness(6, 0, 0, 0), - Background = Brushes.Transparent, - BorderThickness = new Avalonia.Thickness(0), - Foreground = new SolidColorBrush(Color.FromRgb(0xE4, 0xE6, 0xEB)), - VerticalAlignment = VerticalAlignment.Center, - HorizontalContentAlignment = HorizontalAlignment.Center, - VerticalContentAlignment = VerticalAlignment.Center - }; - var header = new StackPanel - { - Orientation = Orientation.Horizontal, - Children = { headerText, closeBtn } - }; - var loadingTab = new TabItem { Header = header, Content = loadingContainer }; - closeBtn.Tag = loadingTab; - closeBtn.Click += ClosePlanTab_Click; - - SubTabControl.Items.Add(loadingTab); - SubTabControl.SelectedItem = loadingTab; - loadingContainer.Focus(); - - try - { - var sw = Stopwatch.StartNew(); - var isAzure = IsAzureConnection; - - var actualPlanXml = await ActualPlanExecutor.ExecuteForActualPlanAsync( - _connectionString, _selectedDatabase, queryText, - planXml, isolationLevel: null, - isAzureSqlDb: isAzure, timeoutSeconds: 0, ct); - - sw.Stop(); - - if (string.IsNullOrEmpty(actualPlanXml)) - { - statusLabel.Text = $"No actual plan returned ({sw.Elapsed.TotalSeconds:F1}s)"; - progressBar.IsVisible = false; - cancelBtn.IsVisible = false; - return; - } - - SetStatus($"Actual plan captured ({sw.Elapsed.TotalSeconds:F1}s)"); - var actualViewer = new PlanViewerControl(); - actualViewer.Metadata = _serverMetadata; - actualViewer.ConnectionString = _connectionString; - actualViewer.SetConnectionServices(_credentialService, _connectionStore); - if (_serverConnection != null) - actualViewer.SetConnectionStatus(_serverConnection.ServerName, _selectedDatabase); - actualViewer.OpenInEditorRequested += OnOpenInEditorRequested; - actualViewer.LoadPlan(actualPlanXml, tabLabel, queryText); - loadingTab.Content = actualViewer; - } - catch (OperationCanceledException) - { - SetStatus("Cancelled"); - SubTabControl.Items.Remove(loadingTab); - } - catch (SqlException ex) - { - statusLabel.Text = ex.Message.Length > 100 ? ex.Message[..100] + "..." : ex.Message; - progressBar.IsVisible = false; - cancelBtn.IsVisible = false; - } - catch (Exception ex) - { - statusLabel.Text = ex.Message.Length > 100 ? ex.Message[..100] + "..." : ex.Message; - progressBar.IsVisible = false; - cancelBtn.IsVisible = false; - } - finally - { - UpdatePlanTabButtonState(); - } - } - - /// - /// Shows a modal confirmation dialog and returns true if the user clicked OK. - /// - private async Task ShowConfirmationDialog(string title, string message) - { - var result = false; - - var messageText = new TextBlock - { - Text = message, - TextWrapping = TextWrapping.Wrap, - FontSize = 13, - Foreground = new SolidColorBrush(Color.Parse("#E4E6EB")), - Margin = new Avalonia.Thickness(0, 0, 0, 16) - }; - - var okBtn = new Button - { - Content = "OK", - Height = 32, - Width = 80, - Padding = new Avalonia.Thickness(16, 0), - FontSize = 12, - HorizontalContentAlignment = HorizontalAlignment.Center, - VerticalContentAlignment = VerticalAlignment.Center, - Theme = (Avalonia.Styling.ControlTheme)this.FindResource("AppButton")! - }; - - var cancelBtn = new Button - { - Content = "Cancel", - Height = 32, - Width = 80, - Padding = new Avalonia.Thickness(16, 0), - FontSize = 12, - Margin = new Avalonia.Thickness(8, 0, 0, 0), - HorizontalContentAlignment = HorizontalAlignment.Center, - VerticalContentAlignment = VerticalAlignment.Center, - Theme = (Avalonia.Styling.ControlTheme)this.FindResource("AppButton")! - }; - - var buttonPanel = new StackPanel - { - Orientation = Avalonia.Layout.Orientation.Horizontal, - HorizontalAlignment = HorizontalAlignment.Right - }; - buttonPanel.Children.Add(okBtn); - buttonPanel.Children.Add(cancelBtn); - - var content = new StackPanel - { - Margin = new Avalonia.Thickness(20), - Children = { messageText, buttonPanel } - }; - - var dialog = new Window - { - Title = title, - Width = 420, - Height = 200, - MinWidth = 420, - MinHeight = 200, - Icon = GetParentWindow().Icon, - Background = new SolidColorBrush(Color.Parse("#1A1D23")), - Foreground = new SolidColorBrush(Color.Parse("#E4E6EB")), - Content = content, - WindowStartupLocation = WindowStartupLocation.CenterOwner - }; - - okBtn.Click += (_, _) => { result = true; dialog.Close(); }; - cancelBtn.Click += (_, _) => dialog.Close(); - - await dialog.ShowDialog(GetParentWindow()); - return result; - } - - /// - /// Extracts the database name from plan XML's StmtSimple DatabaseContext attribute. - /// Returns null if not found. - /// - private static string? ExtractDatabaseFromPlanXml(string? planXml) - { - if (string.IsNullOrEmpty(planXml)) return null; - - try - { - var doc = XDocument.Parse(planXml); - XNamespace ns = "http://schemas.microsoft.com/sqlserver/2004/07/showplan"; - - /* Try StmtSimple first — most queries have this */ - var stmt = doc.Descendants(ns + "StmtSimple").FirstOrDefault(); - var dbContext = stmt?.Attribute("DatabaseContext")?.Value; - - if (!string.IsNullOrEmpty(dbContext)) - { - /* DatabaseContext is typically "[dbname]" — strip brackets */ - return dbContext.Trim('[', ']'); - } - } - catch - { - /* XML parse failure — fall through to null */ - } - - return null; - } - - private Window GetParentWindow() - { - var parent = this.VisualRoot; - return parent as Window ?? throw new InvalidOperationException("No parent window"); - } - - private async void Format_Click(object? sender, RoutedEventArgs e) - { - var sql = QueryEditor.Text; - if (string.IsNullOrWhiteSpace(sql)) - return; - - FormatButton.IsEnabled = false; - SetStatus("Formatting..."); - - try - { - var settings = SqlFormatSettingsService.Load(out var loadError); - if (loadError != null) - SetStatus("Warning: using default format settings (load failed)"); - - var (formatted, errors) = await Task.Run(() => SqlFormattingService.Format(sql, settings)); - - if (errors != null && errors.Count > 0) - { - var errorMessages = string.Join("\n", errors.Select(err => $"Line {err.Line}: {err.Message}")); - var dialog = new Window - { - Title = "SQL Format Error", - Width = 500, - Height = 250, - WindowStartupLocation = WindowStartupLocation.CenterOwner, - Icon = GetParentWindow().Icon, - Background = (IBrush)this.FindResource("BackgroundBrush")!, - Foreground = (IBrush)this.FindResource("ForegroundBrush")!, - Content = new StackPanel - { - Margin = new Avalonia.Thickness(20), - Children = - { - new TextBlock - { - Text = $"Could not format: {errors.Count} parse error(s)", - FontWeight = Avalonia.Media.FontWeight.Bold, - FontSize = 14, - Margin = new Avalonia.Thickness(0, 0, 0, 10) - }, - new TextBlock - { - Text = errorMessages, - TextWrapping = TextWrapping.Wrap, - FontSize = 12 - } - } - } - }; - await dialog.ShowDialog(GetParentWindow()); - SetStatus($"Format failed: {errors.Count} error(s)"); - return; - } - - var caretOffset = QueryEditor.CaretOffset; - - QueryEditor.Document.BeginUpdate(); - try - { - QueryEditor.Document.Replace(0, QueryEditor.Document.TextLength, formatted); - } - finally - { - QueryEditor.Document.EndUpdate(); - } - - QueryEditor.CaretOffset = Math.Min(caretOffset, QueryEditor.Document.TextLength); - SetStatus("Formatted"); - } - finally - { - FormatButton.IsEnabled = true; - } - } - - private void FormatOptions_Click(object? sender, RoutedEventArgs e) - { - var dialog = new Dialogs.FormatOptionsWindow(); - dialog.ShowDialog(GetParentWindow()); - } } From d8c14d160e0e90ba905bfccc712f685dbcdf13c3 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Tue, 12 May 2026 22:29:46 -0500 Subject: [PATCH 15/27] Split PlanAnalyzer.cs into partial classes (#329) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add "Powered by Performance Studio" line on landing page Co-Authored-By: Claude Opus 4.6 (1M context) * Add Darling Data favicon to web app Co-Authored-By: Claude Opus 4.6 (1M context) * Add Open Graph and Twitter Card meta tags for social sharing Uses the Darling Data barbell logo as hero image when shared on social media. Also adds meta description for SEO. Co-Authored-By: Claude Opus 4.6 (1M context) * Clarify OG description: in-browser, nothing to install Co-Authored-By: Claude Opus 4.6 (1M context) * Fix Rule 3 severity: CouldNotGenerateValidParallelPlan is actionable This reason means something in the query blocks parallelism (scalar UDFs, table variable inserts, etc.) — that's worth a Warning, not Info. Co-Authored-By: Claude Opus 4.6 (1M context) * Expand Rule 3 to cover all NonParallelPlanReason values Adds human-readable messages for all 25 known reasons. Severity: - Warning: actionable reasons (UDFs, cursors, table variables, remote queries, trace flags, hints, DML OUTPUT, writeback variables) - Info: passive/environmental (cost below threshold, edition limits, memory-optimized tables, upgrade mode, index build edge cases) Co-Authored-By: Claude Opus 4.6 (1M context) * Split PlanAnalyzer.cs into partial classes Move-only refactor; no behavior changes. PlanAnalyzer.cs (2,238 lines) split into 5 partials: Statement (439) - AnalyzeStatement (statement-level rules) Node (777) - AnalyzeNodeTree + AnalyzeNode (operator rules) Detection (248) - predicate/pattern helpers (HasNotIn, IsAntiSemi, IsRowstoreScan, IsProbeOnly, DetectNonSargable, IsOrExpansionChain, HasAdaptiveJoin, ...) Timing (345) - operator CPU/elapsed helpers + ScanImpact + memory Helpers (275) - severity overrides, wait labels, format, truncate Main file now 115 lines — class declaration, regex constants, static dictionaries (RuleWarningTypes/WarningTypeToRule), static ctor, the public Analyze() entry point, and the ScanImpact record. PlanViewer.Web.csproj updated to link the new partial files. Build clean: 0 errors, 0 warnings on PlanViewer.sln. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- .../Services/PlanAnalyzer.Detection.cs | 270 +++ .../Services/PlanAnalyzer.Helpers.cs | 298 +++ .../Services/PlanAnalyzer.Node.cs | 789 ++++++ .../Services/PlanAnalyzer.Statement.cs | 450 ++++ .../Services/PlanAnalyzer.Timing.cs | 369 +++ src/PlanViewer.Core/Services/PlanAnalyzer.cs | 2129 +---------------- src/PlanViewer.Web/PlanViewer.Web.csproj | 5 + 7 files changed, 2184 insertions(+), 2126 deletions(-) create mode 100644 src/PlanViewer.Core/Services/PlanAnalyzer.Detection.cs create mode 100644 src/PlanViewer.Core/Services/PlanAnalyzer.Helpers.cs create mode 100644 src/PlanViewer.Core/Services/PlanAnalyzer.Node.cs create mode 100644 src/PlanViewer.Core/Services/PlanAnalyzer.Statement.cs create mode 100644 src/PlanViewer.Core/Services/PlanAnalyzer.Timing.cs diff --git a/src/PlanViewer.Core/Services/PlanAnalyzer.Detection.cs b/src/PlanViewer.Core/Services/PlanAnalyzer.Detection.cs new file mode 100644 index 0000000..6b79bf4 --- /dev/null +++ b/src/PlanViewer.Core/Services/PlanAnalyzer.Detection.cs @@ -0,0 +1,270 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using PlanViewer.Core.Models; + +namespace PlanViewer.Core.Services; + +public static partial class PlanAnalyzer +{ + private static bool HasBatchModeNode(PlanNode node) + { + var mode = node.ActualExecutionMode ?? node.ExecutionMode; + if (string.Equals(mode, "Batch", StringComparison.OrdinalIgnoreCase)) + return true; + foreach (var child in node.Children) + { + if (HasBatchModeNode(child)) + return true; + } + return false; + } + + private static void CheckForTableVariables(PlanNode node, bool isModification, + ref bool hasTableVar, ref bool modifiesTableVar) + { + if (!string.IsNullOrEmpty(node.ObjectName) && node.ObjectName.StartsWith("@")) + { + hasTableVar = true; + // The modification target is typically an Insert/Update/Delete operator on a table variable + if (isModification && (node.PhysicalOp.Contains("Insert", StringComparison.OrdinalIgnoreCase) + || node.PhysicalOp.Contains("Update", StringComparison.OrdinalIgnoreCase) + || node.PhysicalOp.Contains("Delete", StringComparison.OrdinalIgnoreCase) + || node.PhysicalOp.Contains("Merge", StringComparison.OrdinalIgnoreCase))) + { + modifiesTableVar = true; + } + } + foreach (var child in node.Children) + CheckForTableVariables(child, isModification, ref hasTableVar, ref modifiesTableVar); + } + + /// + /// Detects the NOT IN with nullable column pattern: statement has NOT IN, + /// and a nearby Nested Loops Anti Semi Join has an IS NULL residual predicate. + /// Checks ancestors and their children (siblings of ancestors) since the IS NULL + /// predicate may be on a sibling Anti Semi Join rather than a direct parent. + /// + private static bool HasNotInPattern(PlanNode spoolNode, PlanStatement stmt) + { + // Check statement text for NOT IN + if (string.IsNullOrEmpty(stmt.StatementText) || + !Regex.IsMatch(stmt.StatementText, @"\bNOT\s+IN\b", RegexOptions.IgnoreCase)) + return false; + + // Walk up the tree checking ancestors and their children + var parent = spoolNode.Parent; + while (parent != null) + { + if (IsAntiSemiJoinWithIsNull(parent)) + return true; + + // Check siblings: the IS NULL predicate may be on a sibling Anti Semi Join + // (e.g. outer NL Anti Semi Join has two children: inner NL Anti Semi Join + Row Count Spool) + foreach (var sibling in parent.Children) + { + if (sibling != spoolNode && IsAntiSemiJoinWithIsNull(sibling)) + return true; + } + + parent = parent.Parent; + } + + return false; + } + + private static bool IsAntiSemiJoinWithIsNull(PlanNode node) => + node.PhysicalOp == "Nested Loops" && + node.LogicalOp.Contains("Anti Semi", StringComparison.OrdinalIgnoreCase) && + !string.IsNullOrEmpty(node.Predicate) && + node.Predicate.Contains("IS NULL", StringComparison.OrdinalIgnoreCase); + + /// + /// Returns true for rowstore scan operators (Index Scan, Clustered Index Scan, + /// Table Scan). Excludes columnstore scans, spools, and constant scans. + /// + private static bool IsRowstoreScan(PlanNode node) + { + return node.PhysicalOp.Contains("Scan", StringComparison.OrdinalIgnoreCase) && + !node.PhysicalOp.Contains("Spool", StringComparison.OrdinalIgnoreCase) && + !node.PhysicalOp.Contains("Constant", StringComparison.OrdinalIgnoreCase) && + !node.PhysicalOp.Contains("Columnstore", StringComparison.OrdinalIgnoreCase); + } + + /// + /// Returns true when the predicate contains ONLY PROBE() bitmap filter(s) + /// with no real residual predicate. PROBE alone is a bitmap filter pushed + /// down from a hash join — not interesting by itself. If a real predicate + /// exists alongside PROBE (e.g. "[col]=(1) AND PROBE(...)"), returns false. + /// + private static bool IsProbeOnly(string predicate) + { + // Strip all PROBE(...) expressions — PROBE args can contain nested parens + var stripped = Regex.Replace(predicate, @"PROBE\s*\([^()]*(?:\([^()]*\)[^()]*)*\)", "", + RegexOptions.IgnoreCase).Trim(); + + // Remove leftover AND/OR connectors and whitespace + stripped = Regex.Replace(stripped, @"\b(AND|OR)\b", "", RegexOptions.IgnoreCase).Trim(); + + // If nothing meaningful remains, it was PROBE-only + return stripped.Length == 0; + } + + /// + /// Strips PROBE(...) bitmap filter expressions from a predicate for display, + /// leaving only the real residual predicate columns. + /// + private static string StripProbeExpressions(string predicate) + { + var stripped = Regex.Replace(predicate, @"\s*AND\s+PROBE\s*\([^()]*(?:\([^()]*\)[^()]*)*\)", "", + RegexOptions.IgnoreCase); + stripped = Regex.Replace(stripped, @"PROBE\s*\([^()]*(?:\([^()]*\)[^()]*)*\)\s*AND\s+", "", + RegexOptions.IgnoreCase); + stripped = Regex.Replace(stripped, @"PROBE\s*\([^()]*(?:\([^()]*\)[^()]*)*\)", "", + RegexOptions.IgnoreCase); + return stripped.Trim(); + } + + /// + /// Returns true for any scan operator including columnstore. + /// Excludes spools and constant scans. + /// + private static bool IsScanOperator(PlanNode node) + { + return node.PhysicalOp.Contains("Scan", StringComparison.OrdinalIgnoreCase) && + !node.PhysicalOp.Contains("Spool", StringComparison.OrdinalIgnoreCase) && + !node.PhysicalOp.Contains("Constant", StringComparison.OrdinalIgnoreCase); + } + + /// + /// Detects non-SARGable patterns in scan predicates. + /// Returns a description of the issue, or null if the predicate is fine. + /// + private static string? DetectNonSargablePredicate(PlanNode node) + { + if (string.IsNullOrEmpty(node.Predicate)) + return null; + + // Only check rowstore scan operators — columnstore is designed to be scanned + if (!IsRowstoreScan(node)) + return null; + + var predicate = node.Predicate; + + // CASE expression in predicate — check first because CASE bodies + // often contain CONVERT_IMPLICIT that isn't the root cause + if (CaseInPredicateRegex.IsMatch(predicate)) + return "CASE expression in predicate"; + + // CONVERT_IMPLICIT — most common non-SARGable pattern + if (predicate.Contains("CONVERT_IMPLICIT", StringComparison.OrdinalIgnoreCase)) + return "Implicit conversion (CONVERT_IMPLICIT)"; + + // ISNULL / COALESCE wrapping column + if (Regex.IsMatch(predicate, @"\b(isnull|coalesce)\s*\(", RegexOptions.IgnoreCase)) + return "ISNULL/COALESCE wrapping column"; + + // Common function calls on columns — but only if the function wraps a column, + // not a parameter/variable. Split on comparison operators to check which side + // the function is on. Predicate format: [db].[schema].[table].[col]>func(...) + var funcMatch = FunctionInPredicateRegex.Match(predicate); + if (funcMatch.Success) + { + var funcName = funcMatch.Groups[1].Value.ToUpperInvariant(); + if (funcName != "CONVERT_IMPLICIT" && IsFunctionOnColumnSide(predicate, funcMatch)) + return $"Function call ({funcName}) on column"; + } + + // Leading wildcard LIKE + if (LeadingWildcardLikeRegex.IsMatch(predicate)) + return "Leading wildcard LIKE pattern"; + + return null; + } + + /// + /// Checks whether a function call in a predicate is on the column side of the comparison. + /// Predicate ScalarStrings look like: [db].[schema].[table].[col]>dateadd(day,(0),[@var]) + /// If the function is only on the parameter/literal side, it's still SARGable. + /// + private static bool IsFunctionOnColumnSide(string predicate, Match funcMatch) + { + // Find the comparison operator that splits the predicate into left/right sides. + // Operators in ScalarString: >=, <=, <>, >, <, = + var compMatch = Regex.Match(predicate, @"(?])([<>=!]{1,2})(?![<>=])"); + if (!compMatch.Success) + return true; // No comparison found — can't determine side, assume worst case + + var compPos = compMatch.Index; + var funcPos = funcMatch.Index; + + // Determine which side the function is on + var funcSide = funcPos < compPos ? "left" : "right"; + + // Check if that side also contains a column reference [...].[...].[...] + string side = funcSide == "left" + ? predicate[..compPos] + : predicate[(compPos + compMatch.Length)..]; + + // Column references are multi-part bracket-qualified: [schema].[table].[column] + // Variables are [@var] or [@var] — single bracket pair with @ prefix. + // Match [identifier].[identifier] (at least two dotted parts) to distinguish columns. + return Regex.IsMatch(side, @"\[[^\]@]+\]\.\["); + } + + /// + /// Verifies the OR expansion chain walking up from a Concatenation node: + /// Nested Loops → Merge Interval → TopN Sort → [Compute Scalar] → Concatenation + /// + private static bool IsOrExpansionChain(PlanNode concatenationNode) + { + // Walk up, skipping Compute Scalar + var parent = concatenationNode.Parent; + while (parent != null && parent.PhysicalOp == "Compute Scalar") + parent = parent.Parent; + + // Expect TopN Sort (XML says "TopN Sort", parser normalizes to "Top N Sort") + if (parent == null || parent.LogicalOp != "Top N Sort") + return false; + + // Walk up to Merge Interval + parent = parent.Parent; + if (parent == null || parent.PhysicalOp != "Merge Interval") + return false; + + // Walk up to Nested Loops + parent = parent.Parent; + if (parent == null || parent.PhysicalOp != "Nested Loops") + return false; + + // If this Nested Loops is inside an Anti/Semi Join, this is a NOT IN/IN + // subquery pattern (Merge Interval optimizing range lookups), not an OR expansion + var nlParent = parent.Parent; + if (nlParent != null && nlParent.LogicalOp != null && + nlParent.LogicalOp.Contains("Semi")) + return false; + + return true; + } + + /// + /// Finds Sort and Hash Match operators in the tree that consume memory. + /// + /// + /// Returns true if the plan contains an adaptive join that executed as a Nested Loop. + /// Indicates a memory grant was sized for the hash alternative but never needed. + /// + private static bool HasAdaptiveJoinChoseNestedLoop(PlanNode node) + { + if (node.IsAdaptive && node.ActualJoinType != null + && node.ActualJoinType.Contains("Nested", StringComparison.OrdinalIgnoreCase)) + return true; + + foreach (var child in node.Children) + if (HasAdaptiveJoinChoseNestedLoop(child)) + return true; + + return false; + } +} diff --git a/src/PlanViewer.Core/Services/PlanAnalyzer.Helpers.cs b/src/PlanViewer.Core/Services/PlanAnalyzer.Helpers.cs new file mode 100644 index 0000000..6bf7585 --- /dev/null +++ b/src/PlanViewer.Core/Services/PlanAnalyzer.Helpers.cs @@ -0,0 +1,298 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using PlanViewer.Core.Models; + +namespace PlanViewer.Core.Services; + +public static partial class PlanAnalyzer +{ + private static void MarkLegacyWarnings(PlanStatement stmt) + { + foreach (var w in stmt.PlanWarnings) + { + if (LegacyWarningTypes.Contains(w.WarningType)) + w.IsLegacy = true; + } + if (stmt.RootNode != null) + MarkLegacyWarningsOnTree(stmt.RootNode); + } + + private static void MarkLegacyWarningsOnTree(PlanNode node) + { + foreach (var w in node.Warnings) + { + if (LegacyWarningTypes.Contains(w.WarningType)) + w.IsLegacy = true; + } + foreach (var child in node.Children) + MarkLegacyWarningsOnTree(child); + } + + private static void ApplySeverityOverrides(ParsedPlan plan, AnalyzerConfig cfg) + { + foreach (var batch in plan.Batches) + { + foreach (var stmt in batch.Statements) + { + foreach (var w in stmt.PlanWarnings) + TryOverrideSeverity(w, cfg); + + if (stmt.RootNode != null) + ApplyOverridesToTree(stmt.RootNode, cfg); + } + } + } + + private static void ApplyOverridesToTree(PlanNode node, AnalyzerConfig cfg) + { + foreach (var w in node.Warnings) + TryOverrideSeverity(w, cfg); + foreach (var child in node.Children) + ApplyOverridesToTree(child, cfg); + } + + private static void TryOverrideSeverity(PlanWarning warning, AnalyzerConfig cfg) + { + // Find the rule number for this warning type (partial match for flexibility) + int? ruleNumber = null; + foreach (var (rule, type) in RuleWarningTypes) + { + if (warning.WarningType.Contains(type, StringComparison.OrdinalIgnoreCase) || + type.Contains(warning.WarningType, StringComparison.OrdinalIgnoreCase)) + { + ruleNumber = rule; + break; + } + } + + if (ruleNumber == null) return; + + var overrideSeverity = cfg.GetSeverityOverride(ruleNumber.Value); + if (overrideSeverity == null) return; + + if (Enum.TryParse(overrideSeverity, ignoreCase: true, out var severity)) + warning.Severity = severity; + } + + /// Determines whether a row estimate mismatch actually caused observable harm. + /// Returns a description of the harm, or null if the bad estimate is benign. + /// + /// False-positive suppression (from reviewer feedback): + /// - Root node (no parent) — nothing above to be harmed by the bad estimate + /// - Sort that didn't spill — the estimate was wrong but no harm done + /// + /// Real harm: + /// - The node itself has a spill warning (bad estimate → bad memory grant) + /// - The node is a join (wrong join type or excessive inner side work) + /// - A parent join may have chosen the wrong strategy based on bad row count + /// - A parent Sort/Hash spilled (downstream estimate caused bad grant) + /// + /// + /// Returns a short label describing what a wait type means (e.g., "I/O — reading from disk"). + /// Public for use by UI components that annotate wait stats inline. + /// + public static string GetWaitLabel(string waitType) + { + var wt = waitType.ToUpperInvariant(); + return wt switch + { + _ when wt.StartsWith("PAGEIOLATCH") => "I/O — reading data from disk", + _ when wt.Contains("IO_COMPLETION") => "I/O — spills to TempDB or eager writes", + _ when wt == "SOS_SCHEDULER_YIELD" => "CPU — scheduler yielding", + _ when wt.StartsWith("CXPACKET") || wt.StartsWith("CXCONSUMER") => "parallelism — thread skew", + _ when wt.StartsWith("CXSYNC") => "parallelism — exchange synchronization", + _ when wt == "HTBUILD" => "hash — building hash table", + _ when wt == "HTDELETE" => "hash — cleaning up hash table", + _ when wt == "HTREPARTITION" => "hash — repartitioning", + _ when wt.StartsWith("HT") => "hash operation", + _ when wt == "BPSORT" => "batch sort", + _ when wt == "BMPBUILD" => "bitmap filter build", + _ when wt.Contains("MEMORY_ALLOCATION_EXT") => "memory allocation", + _ when wt.StartsWith("PAGELATCH") => "page latch — in-memory contention", + _ when wt.StartsWith("LATCH_") => "latch contention", + _ when wt.StartsWith("LCK_") => "lock contention", + _ when wt == "LOGBUFFER" => "transaction log writes", + _ when wt == "ASYNC_NETWORK_IO" => "network — client not consuming results", + _ when wt == "SOS_PHYS_PAGE_CACHE" => "physical page cache contention", + _ => "" + }; + } + + /// + /// Returns true if the statement has significant I/O waits (PAGEIOLATCH_*, IO_COMPLETION). + /// Used for severity elevation decisions where I/O specifically indicates disk access. + /// Thresholds: I/O waits >= 20% of total wait time AND >= 100ms absolute. + /// + private static bool HasSignificantIoWaits(List waits) + { + if (waits.Count == 0) + return false; + + var totalMs = waits.Sum(w => w.WaitTimeMs); + if (totalMs == 0) + return false; + + long ioMs = 0; + foreach (var w in waits) + { + var wt = w.WaitType.ToUpperInvariant(); + if (wt.StartsWith("PAGEIOLATCH") || wt.Contains("IO_COMPLETION")) + ioMs += w.WaitTimeMs; + } + + var pct = (double)ioMs / totalMs * 100; + return ioMs >= 100 && pct >= 20; + } + + private static bool AllocatesResources(PlanNode node) + { + // Operators that get memory grants or allocate structures based on row estimates. + // Hash Match (hash table), Sort (sort buffer), Spool (worktable). + var op = node.PhysicalOp; + return op.StartsWith("Hash", StringComparison.OrdinalIgnoreCase) + || op.StartsWith("Sort", StringComparison.OrdinalIgnoreCase) + || op.EndsWith("Spool", StringComparison.OrdinalIgnoreCase); + } + + private static string? AssessEstimateHarm(PlanNode node, double ratio) + { + // Root node: no parent to harm. + // The synthetic statement root (SELECT/INSERT/etc.) has NodeId == -1. + if (node.Parent == null || node.Parent.NodeId == -1) + return null; + + // The node itself has a spill — bad estimate caused bad memory grant + if (HasSpillWarning(node)) + { + return ratio >= 10.0 + ? "The underestimate likely caused an insufficient memory grant, leading to a spill to TempDB." + : "The overestimate may have caused an excessive memory grant, wasting workspace memory."; + } + + // Sort/Hash that did NOT spill — estimate was wrong but no observable harm + if ((node.PhysicalOp.Contains("Sort", StringComparison.OrdinalIgnoreCase) || + node.PhysicalOp.Contains("Hash", StringComparison.OrdinalIgnoreCase)) && + !HasSpillWarning(node)) + { + return null; + } + + // The node is a join — bad estimate means wrong join type or excessive work + // Adaptive joins (2017+) switch strategy at runtime, so the estimate didn't lock in a bad choice. + if (node.LogicalOp.Contains("Join", StringComparison.OrdinalIgnoreCase) && !node.IsAdaptive) + { + return ratio >= 10.0 + ? "The underestimate may have caused the optimizer to make poor choices." + : "The overestimate may have caused the optimizer to make poor choices."; + } + + // Walk up to check if a parent was harmed by this bad estimate + var ancestor = node.Parent; + while (ancestor != null) + { + // Transparent operators — skip through + if (ancestor.PhysicalOp == "Parallelism" || + ancestor.PhysicalOp == "Compute Scalar" || + ancestor.PhysicalOp == "Segment" || + ancestor.PhysicalOp == "Sequence Project" || + ancestor.PhysicalOp == "Top" || + ancestor.PhysicalOp == "Filter") + { + ancestor = ancestor.Parent; + continue; + } + + // Parent join — bad row count from below caused wrong join choice + // Adaptive joins handle this at runtime, so skip them. + if (ancestor.LogicalOp.Contains("Join", StringComparison.OrdinalIgnoreCase)) + { + if (ancestor.IsAdaptive) + return null; // Adaptive join self-corrects — no harm + + return ratio >= 10.0 + ? $"The underestimate may have caused the optimizer to make poor choices." + : $"The overestimate may have caused the optimizer to make poor choices."; + } + + // Parent Sort/Hash that spilled — downstream bad estimate caused the spill + if (HasSpillWarning(ancestor)) + { + return ratio >= 10.0 + ? $"The underestimate contributed to {ancestor.PhysicalOp} (Node {ancestor.NodeId}) spilling to TempDB." + : $"The overestimate contributed to {ancestor.PhysicalOp} (Node {ancestor.NodeId}) receiving an excessive memory grant."; + } + + // Parent Sort/Hash with no spill — benign + if (ancestor.PhysicalOp.Contains("Sort", StringComparison.OrdinalIgnoreCase) || + ancestor.PhysicalOp.Contains("Hash", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + // Any other operator — stop walking + break; + } + + // Default: the estimate is off but we can't identify specific harm + return null; + } + + /// + /// Checks if a node has any spill-related warnings (Sort/Hash/Exchange spills). + /// + private static bool HasSpillWarning(PlanNode node) + { + return node.Warnings.Any(w => w.SpillDetails != null); + } + + /// + /// Formats a node reference for use in warning messages. Includes object name + /// for data access operators where it helps identify which table is involved. + /// + private static string FormatNodeRef(PlanNode node) + { + if (!string.IsNullOrEmpty(node.ObjectName)) + { + var objRef = !string.IsNullOrEmpty(node.DatabaseName) + ? $"{node.DatabaseName}.{node.ObjectName}" + : node.ObjectName; + return $"{node.PhysicalOp} on {objRef} (Node {node.NodeId})"; + } + + return $"{node.PhysicalOp} (Node {node.NodeId})"; + } + + /// + /// Identifies the specific cause of a row goal from the statement text. + /// Returns a specific cause when detectable, or a generic list as fallback. + /// + private static string IdentifyRowGoalCause(string stmtText) + { + if (string.IsNullOrEmpty(stmtText)) + return "TOP, EXISTS, IN, or FAST hint"; + + var text = stmtText.ToUpperInvariant(); + var causes = new List(4); + + if (Regex.IsMatch(text, @"\bTOP\b")) + causes.Add("TOP"); + if (Regex.IsMatch(text, @"\bEXISTS\b")) + causes.Add("EXISTS"); + // IN with subquery — bare "IN (" followed by SELECT, not just "IN (1,2,3)" + if (Regex.IsMatch(text, @"\bIN\s*\(\s*SELECT\b")) + causes.Add("IN (subquery)"); + if (Regex.IsMatch(text, @"\bFAST\b")) + causes.Add("FAST hint"); + + return causes.Count > 0 + ? string.Join(", ", causes) + : "TOP, EXISTS, IN, or FAST hint"; + } + + private static string Truncate(string value, int maxLength) + { + return value.Length <= maxLength ? value : value[..maxLength] + "..."; + } +} diff --git a/src/PlanViewer.Core/Services/PlanAnalyzer.Node.cs b/src/PlanViewer.Core/Services/PlanAnalyzer.Node.cs new file mode 100644 index 0000000..0b3ef69 --- /dev/null +++ b/src/PlanViewer.Core/Services/PlanAnalyzer.Node.cs @@ -0,0 +1,789 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using PlanViewer.Core.Models; + +namespace PlanViewer.Core.Services; + +public static partial class PlanAnalyzer +{ + private static void AnalyzeNodeTree(PlanNode node, PlanStatement stmt, AnalyzerConfig cfg) + { + AnalyzeNode(node, stmt, cfg); + + foreach (var child in node.Children) + AnalyzeNodeTree(child, stmt, cfg); + } + + private static void AnalyzeNode(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). + // Suppress when the filter's child subtree is trivial (low I/O, fast, cheap). + if (!cfg.IsRuleDisabled(1) && node.PhysicalOp == "Filter" && !string.IsNullOrEmpty(node.Predicate) + && node.Children.Count > 0) + { + // Gate: skip trivial filters based on actual stats or estimated cost + bool isTrivial; + if (node.HasActualStats) + { + long childReads = 0; + foreach (var child in node.Children) + childReads += SumSubtreeReads(child); + var childElapsed = node.Children.Max(c => c.ActualElapsedMs); + isTrivial = childReads < 128 && childElapsed < 10; + } + else + { + var childCost = node.Children.Sum(c => c.EstimatedTotalSubtreeCost); + isTrivial = childCost < 1.0; + } + + if (!isTrivial) + { + var impact = QuantifyFilterImpact(node); + var predicate = Truncate(node.Predicate, 200); + var message = "Filter operator discarding rows late in the plan."; + if (!string.IsNullOrEmpty(impact)) + message += $"\n{impact}"; + message += $"\nPredicate: {predicate}"; + + node.Warnings.Add(new PlanWarning + { + WarningType = "Filter Operator", + Message = message, + Severity = PlanWarningSeverity.Warning + }); + } + } + + // 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)) + { + var message = "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."; + if (!string.IsNullOrEmpty(node.SuggestedIndex)) + message += $"\n\nCreate this index:\n{node.SuggestedIndex}"; + + node.Warnings.Add(new PlanWarning + { + WarningType = "Eager Index Spool", + Message = message, + Severity = PlanWarningSeverity.Critical + }); + } + + // Rule 4: UDF timing — any node spending time in UDFs (actual plans) + if (!cfg.IsRuleDisabled(4) && (node.UdfCpuTimeMs > 0 || node.UdfElapsedTimeMs > 0)) + { + node.Warnings.Add(new PlanWarning + { + WarningType = "UDF Execution", + Message = $"Scalar UDF executing on this operator ({node.UdfElapsedTimeMs:N0}ms elapsed, {node.UdfCpuTimeMs:N0}ms 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.", + Severity = node.UdfElapsedTimeMs >= 1000 ? PlanWarningSeverity.Critical : PlanWarningSeverity.Warning + }); + } + + // 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) + // - A parent join may have chosen the wrong strategy + // - Root nodes with no parent to harm are skipped + // - Nodes whose only parents are Parallelism/Top/Sort (no spill) are skipped + if (!cfg.IsRuleDisabled(5) && node.HasActualStats && node.EstimateRows > 0 + && !node.Lookup) // Key lookups are point lookups (1 row per execution) — per-execution estimate is misleading + { + if (node.ActualRows == 0) + { + // Zero rows with a significant estimate — only warn on operators that + // actually allocate meaningful resources (memory grants for hash/sort/spool). + // Skip Parallelism, Bitmap, Compute Scalar, Filter, Concatenation, etc. + // where 0 rows is just a consequence of upstream filtering. + if (node.EstimateRows >= 100 && AllocatesResources(node)) + { + node.Warnings.Add(new PlanWarning + { + WarningType = "Row Estimate Mismatch", + Message = $"Estimated {node.EstimateRows:N0} rows but actual 0 rows returned. SQL Server allocated resources for rows that never materialized.", + Severity = PlanWarningSeverity.Warning + }); + } + } + else + { + // Compare per-execution actuals to estimates (SQL Server estimates are per-execution) + var executions = node.ActualExecutions > 0 ? node.ActualExecutions : 1; + var actualPerExec = (double)node.ActualRows / executions; + var ratio = actualPerExec / node.EstimateRows; + if (ratio >= 10.0 || ratio <= 0.1) + { + var harm = AssessEstimateHarm(node, ratio); + if (harm != null) + { + var direction = ratio >= 10.0 ? "underestimated" : "overestimated"; + var factor = ratio >= 10.0 ? ratio : 1.0 / ratio; + var actualDisplay = executions > 1 + ? $"Actual {node.ActualRows:N0} ({actualPerExec:N0} rows x {executions:N0} executions)" + : $"Actual {node.ActualRows:N0}"; + node.Warnings.Add(new PlanWarning + { + WarningType = "Row Estimate Mismatch", + Message = $"Estimated {node.EstimateRows:N0} vs {actualDisplay} — {factor:F0}x {direction}. {harm}", + Severity = factor >= 100 ? PlanWarningSeverity.Critical : PlanWarningSeverity.Warning + }); + } + } + } + } + + // 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. + var serialPlanCoversUdf = stmt.NonParallelPlanReason is + "TSQLUserDefinedFunctionsNotParallelizable" + or "CLRUserDefinedFunctionRequiresDataAccess" + or "CouldNotGenerateValidParallelPlan"; + if (!cfg.IsRuleDisabled(6) && !serialPlanCoversUdf) + foreach (var udf in node.ScalarUdfs) + { + var type = udf.IsClrFunction ? "CLR" : "T-SQL"; + node.Warnings.Add(new PlanWarning + { + WarningType = "Scalar UDF", + Message = $"Scalar {type} UDF: {udf.FunctionName}. 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.", + Severity = PlanWarningSeverity.Warning + }); + } + + // 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 + // timing is unreliable but the write count tells the story. + if (!cfg.IsRuleDisabled(7)) + foreach (var w in node.Warnings.ToList()) + { + if (w.SpillDetails == null) + continue; + + var isExchangeSpill = w.SpillDetails.SpillType == "Exchange"; + + if (isExchangeSpill) + { + // Exchange spills: severity based on write count since timing is unreliable + var writes = w.SpillDetails.WritesToTempDb; + if (writes >= 1_000_000) + w.Severity = PlanWarningSeverity.Critical; + else if (writes >= 10_000) + w.Severity = PlanWarningSeverity.Warning; + + // Surface Parallelism operator time when available (actual plans) + if (node.ActualElapsedMs > 0) + { + var operatorMs = GetParallelismOperatorElapsedMs(node); + var stmtMs = stmt.QueryTimeStats?.ElapsedTimeMs ?? 0; + if (stmtMs > 0 && operatorMs > 0) + { + var pct = (double)operatorMs / stmtMs; + w.Message += $" Operator time: {operatorMs:N0}ms ({pct:P0} of statement)."; + } + } + } + else if (node.ActualElapsedMs > 0) + { + // Sort/Hash spills: severity based on operator time percentage + var operatorMs = GetOperatorOwnElapsedMs(node); + var stmtMs = stmt.QueryTimeStats?.ElapsedTimeMs ?? 0; + + if (stmtMs > 0) + { + var pct = (double)operatorMs / stmtMs; + w.Message += $" Operator time: {operatorMs:N0}ms ({pct:P0} of statement)."; + + if (pct >= 0.5) + w.Severity = PlanWarningSeverity.Critical; + else if (pct >= 0.1) + w.Severity = PlanWarningSeverity.Warning; + } + } + } + + // 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 + if (!cfg.IsRuleDisabled(8) && node.PerThreadStats.Count > 1) + { + var workerThreads = node.PerThreadStats.Where(t => t.ThreadId > 0).ToList(); + if (workerThreads.Count < 2) workerThreads = node.PerThreadStats; // fallback + var totalRows = workerThreads.Sum(t => t.ActualRows); + var minRowsForSkew = workerThreads.Count * 1000; + if (totalRows >= minRowsForSkew) + { + var maxThread = workerThreads.OrderByDescending(t => t.ActualRows).First(); + var skewRatio = (double)maxThread.ActualRows / totalRows; + // At DOP 2, a 60/40 split is normal — use higher threshold + 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 severity = PlanWarningSeverity.Warning; + + // Batch mode sorts produce all output on a single thread by design + // unless their parent is a batch mode Window Aggregate + if (node.PhysicalOp == "Sort" + && (node.ActualExecutionMode ?? node.ExecutionMode) == "Batch" + && node.Parent?.PhysicalOp != "Window Aggregate") + { + message += " Batch mode sorts produce all output rows on a single thread by design, unless feeding a batch mode Window Aggregate."; + severity = PlanWarningSeverity.Info; + } + else + { + // Add practical context — skew is often hard to fix + message += " 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."; + } + + node.Warnings.Add(new PlanWarning + { + WarningType = "Parallel Skew", + Message = message, + Severity = severity + }); + } + } + } + + // 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)) + { + var message = "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."; + if (!string.IsNullOrEmpty(node.Predicate)) + message += $" Predicate: {Truncate(node.Predicate, 200)}"; + + node.Warnings.Add(new PlanWarning + { + WarningType = "RID Lookup", + Message = message, + Severity = PlanWarningSeverity.Warning + }); + } + else if (!cfg.IsRuleDisabled(10) && node.Lookup) + { + var lookupMsg = "Key Lookup — SQL Server found rows via a nonclustered index but had to go back to the clustered index for additional columns."; + + // Show what columns the lookup is fetching + if (!string.IsNullOrEmpty(node.OutputColumns)) + lookupMsg += $"\nColumns fetched: {Truncate(node.OutputColumns, 200)}"; + + // Only call out the predicate if it actually filters rows + if (!string.IsNullOrEmpty(node.Predicate)) + { + var predicateFilters = node.HasActualStats && node.ActualExecutions > 0 + && node.ActualRows < node.ActualExecutions; + if (predicateFilters) + lookupMsg += $"\nResidual predicate (filtered {node.ActualExecutions - node.ActualRows:N0} rows): {Truncate(node.Predicate, 200)}"; + } + + lookupMsg += "\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."; + + node.Warnings.Add(new PlanWarning + { + WarningType = "Key Lookup", + Message = lookupMsg, + Severity = PlanWarningSeverity.Critical + }); + } + + // 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); + if (nonSargableReason != null) + { + var nonSargableAdvice = nonSargableReason switch + { + "Implicit conversion (CONVERT_IMPLICIT)" => + "Implicit conversion (CONVERT_IMPLICIT) prevents an index seek. Match the parameter or variable data type to the column data type.", + "ISNULL/COALESCE wrapping column" => + "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\".", + "Leading wildcard LIKE pattern" => + "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.", + "CASE expression in predicate" => + "CASE expression in a predicate prevents an index seek. Rewrite using separate WHERE clauses combined with OR, or split into multiple queries.", + _ when nonSargableReason.StartsWith("Function call") => + $"{nonSargableReason} 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.", + _ => + $"{nonSargableReason} prevents an index seek, forcing a scan." + }; + + node.Warnings.Add(new PlanWarning + { + WarningType = "Non-SARGable Predicate", + Message = $"{nonSargableAdvice}\nPredicate: {Truncate(node.Predicate!, 200)}", + Severity = PlanWarningSeverity.Warning + }); + } + + // 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) && + !IsProbeOnly(node.Predicate) && !(node.HasActualStats && node.ActualExecutions == 0)) + { + var displayPredicate = StripProbeExpressions(node.Predicate); + var details = BuildScanImpactDetails(node, stmt); + var severity = PlanWarningSeverity.Warning; + + // Elevate to Critical if the scan dominates the plan + if (details.CostPct >= 90 || details.ElapsedPct >= 90) + severity = PlanWarningSeverity.Critical; + + var message = "Scan with residual predicate — SQL Server is reading every row and filtering after the fact."; + if (!string.IsNullOrEmpty(details.Summary)) + message += $" {details.Summary}"; + + // #215 E2: if the statement is executing a dynamic cursor, that's usually + // the reason an index didn't get used. Call it out so the user looks there + // first rather than hunting for a missing index. + var isDynamicCursor = string.Equals(stmt.CursorActualType, "Dynamic", + StringComparison.OrdinalIgnoreCase); + if (isDynamicCursor) + message += " This query is running inside a dynamic cursor, which can prevent index usage; changing the cursor type (FAST_FORWARD / STATIC / KEYSET) often fixes scans like this without any indexing change."; + else + message += " Check that you have appropriate indexes."; + + // I/O waits specifically confirm the scan is hitting disk — elevate + if (HasSignificantIoWaits(stmt.WaitStats) && details.CostPct >= 50 + && severity != PlanWarningSeverity.Critical) + severity = PlanWarningSeverity.Critical; + + message += $"\nPredicate: {Truncate(displayPredicate, 200)}"; + + node.Warnings.Add(new PlanWarning + { + WarningType = "Scan With Predicate", + Message = message, + Severity = severity + }); + } + + // 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. + // With accurate estimates, it would likely seek instead. + if (!cfg.IsRuleDisabled(32) && node.HasActualStats && IsRowstoreScan(node) + && node.EstimateRows > 0 && node.ActualRows >= 0 && node.ActualRowsRead > 0) + { + var impact = BuildScanImpactDetails(node, stmt); + var overestimateRatio = node.EstimateRows / Math.Max(1.0, node.ActualRows); + var selectivity = (double)node.ActualRows / node.ActualRowsRead; + + // Fire when: scan is >= 50% of plan, estimate is >= 10x actual, and < 10% selectivity + if ((impact.CostPct >= 50 || impact.ElapsedPct >= 50) + && overestimateRatio >= 10.0 + && selectivity < 0.10) + { + node.Warnings.Add(new PlanWarning + { + WarningType = "Scan Cardinality Misestimate", + Message = $"Estimated {node.EstimateRows:N0} rows but only {node.ActualRows:N0} returned ({selectivity * 100:N3}% of {node.ActualRowsRead:N0} rows read). " + + $"The {overestimateRatio:N0}x overestimate likely caused the optimizer to choose a scan instead of a seek. " + + $"An index on the predicate columns could dramatically reduce I/O.", + Severity = PlanWarningSeverity.Critical + }); + } + } + + // 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%)), + // 1% (multi-inequality). On large tables, these guesses can hide the need for an index. + if (!cfg.IsRuleDisabled(33) && !node.HasActualStats && IsRowstoreScan(node) + && node.TableCardinality >= 100_000 && node.EstimateRows > 0 + && !string.IsNullOrEmpty(node.Predicate)) + { + var impact = BuildScanImpactDetails(node, stmt); + if (impact.CostPct >= 50) + { + var guessDesc = DetectCeGuess(node.EstimateRows, node.TableCardinality); + if (guessDesc != null) + { + node.Warnings.Add(new PlanWarning + { + WarningType = "Estimated Plan CE Guess", + Message = $"Estimated {node.EstimateRows:N0} rows from {node.TableCardinality:N0} row table — {guessDesc}. " + + $"The optimizer may be using a default guess instead of accurate statistics. " + + $"If actual selectivity is much lower, an index on the predicate columns could help significantly.", + Severity = PlanWarningSeverity.Warning + }); + } + } + } + + // 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 + // cover the query with far less I/O. For analytical workloads, columnstore may + // be a better fit. + var isBareScanCandidate = (node.PhysicalOp == "Clustered Index Scan" || node.PhysicalOp == "Table Scan") + && !node.Lookup + && string.IsNullOrEmpty(node.Predicate) + && !string.IsNullOrEmpty(node.OutputColumns); + if (!cfg.IsRuleDisabled(34) && isBareScanCandidate) + { + var colCount = node.OutputColumns!.Split(',').Length; + var isSignificant = node.HasActualStats + ? GetOperatorOwnElapsedMs(node) > 0 + : node.CostPercent >= 20; + + if (isSignificant) + { + var scanKind = node.PhysicalOp == "Clustered Index Scan" + ? "Clustered index scan" + : "Heap table scan"; + + if (colCount <= 3) + { + // Narrow output: a nonclustered rowstore index can cover this cheaply. + var indexAdvice = node.PhysicalOp == "Clustered Index Scan" + ? "Consider a nonclustered index on the output columns (as key or INCLUDE) so SQL Server can read a narrower structure." + : "Consider a clustered or nonclustered index on the output columns so SQL Server can read a narrower structure."; + + node.Warnings.Add(new PlanWarning + { + WarningType = "Bare Scan", + Message = $"{scanKind} reads the full table with no predicate, outputting {colCount} column(s): {Truncate(node.OutputColumns, 200)}. {indexAdvice} For analytical workloads, a columnstore index may be a better fit.", + Severity = PlanWarningSeverity.Warning + }); + } + else + { + // Wider output: rowstore NC index isn't a great fit (would have to + // carry too many columns), but columnstore doesn't care about column + // count. Suggest it for analytical / aggregate-style workloads. + node.Warnings.Add(new PlanWarning + { + WarningType = "Bare Scan", + Message = $"{scanKind} reads the full table with no predicate, outputting {colCount} 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.", + Severity = PlanWarningSeverity.Warning + }); + } + } + } + + // Rule 13: Mismatched data types (GetRangeWithMismatchedTypes / GetRangeThroughConvert) + if (!cfg.IsRuleDisabled(13) && node.PhysicalOp == "Compute Scalar" && !string.IsNullOrEmpty(node.DefinedValues)) + { + var hasMismatch = node.DefinedValues.Contains("GetRangeWithMismatchedTypes", StringComparison.OrdinalIgnoreCase); + var hasConvert = node.DefinedValues.Contains("GetRangeThroughConvert", StringComparison.OrdinalIgnoreCase); + + if (hasMismatch || hasConvert) + { + var reason = hasMismatch + ? "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." + : "CONVERT/CAST wrapping a column in the predicate. SQL Server is converting every row to compare, preventing index seeks. Match your data types — convert the parameter/literal instead of the column."; + + node.Warnings.Add(new PlanWarning + { + WarningType = "Data Type Mismatch", + Message = reason, + Severity = PlanWarningSeverity.Warning + }); + } + } + + // 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) + // so rebind/rewind counts are unreliable. See https://www.sql.kiwi/2025/02/lazy-index-spool/ + if (!cfg.IsRuleDisabled(14) && node.LogicalOp == "Lazy Spool" + && !node.PhysicalOp.Contains("Index", StringComparison.OrdinalIgnoreCase)) + { + var rebinds = node.HasActualStats ? (double)node.ActualRebinds : node.EstimateRebinds; + var rewinds = node.HasActualStats ? (double)node.ActualRewinds : node.EstimateRewinds; + var source = node.HasActualStats ? "actual" : "estimated"; + + if (rebinds > 100 && rewinds < rebinds * 5) + { + var severity = rewinds < rebinds + ? PlanWarningSeverity.Critical + : PlanWarningSeverity.Warning; + + var ratio = rewinds > 0 + ? $"{rewinds / rebinds:F1}x rewinds (cache hits) per rebind (cache miss)" + : "no rewinds (cache hits) at all"; + + node.Warnings.Add(new PlanWarning + { + WarningType = "Lazy Spool Ineffective", + Message = $"Lazy spool has low cache hit ratio ({source}): {rebinds:N0} rebinds (cache misses), {rewinds:N0} rewinds (cache hits) — {ratio}. The spool is caching results but rarely reusing them, adding overhead for no benefit.", + Severity = severity + }); + } + } + + // 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") + { + var constantScanBranches = node.Children + .Count(c => c.PhysicalOp == "Constant Scan" || + (c.PhysicalOp == "Compute Scalar" && + c.Children.Any(gc => gc.PhysicalOp == "Constant Scan"))); + + if (constantScanBranches >= 2 && IsOrExpansionChain(node)) + { + node.Warnings.Add(new PlanWarning + { + WarningType = "Join OR Clause", + Message = $"OR in a join predicate. SQL Server rewrote the OR as {constantScanBranches} 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\".", + Severity = PlanWarningSeverity.Warning + }); + } + } + + // 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" && + node.LogicalOp.Contains("Join", StringComparison.OrdinalIgnoreCase) && + !node.IsAdaptive && + node.Children.Count >= 2) + { + var outerChild = node.Children[0]; + var innerChild = node.Children[1]; + + if (innerChild.HasActualStats && innerChild.ActualExecutions > 100000) + { + var dop = stmt.DegreeOfParallelism > 0 ? stmt.DegreeOfParallelism : 1; + var details = new List(); + + // Core fact + details.Add($"Nested Loops inner side executed {innerChild.ActualExecutions:N0} times (DOP {dop})."); + + // Outer side estimate mismatch — explains WHY the optimizer chose NL + if (outerChild.HasActualStats && outerChild.EstimateRows > 0) + { + var outerExecs = outerChild.ActualExecutions > 0 ? outerChild.ActualExecutions : 1; + var outerActualPerExec = (double)outerChild.ActualRows / outerExecs; + var outerRatio = outerActualPerExec / outerChild.EstimateRows; + if (outerRatio >= 10.0) + { + details.Add($"Outer side: estimated {outerChild.EstimateRows:N0} rows, actual {outerActualPerExec:N0} ({outerRatio:F0}x underestimate). The optimizer chose Nested Loops expecting far fewer iterations."); + } + } + + // Inner side cost — reads and time spent doing the repeated work + long innerReads = SumSubtreeReads(innerChild); + if (innerReads > 0) + details.Add($"Inner side total: {innerReads:N0} logical reads."); + + if (innerChild.ActualElapsedMs > 0) + { + var stmtMs = stmt.QueryTimeStats?.ElapsedTimeMs ?? 0; + if (stmtMs > 0) + { + var pct = (double)innerChild.ActualElapsedMs / stmtMs * 100; + details.Add($"Inner side time: {innerChild.ActualElapsedMs:N0}ms ({pct:N0}% of statement)."); + } + else + { + details.Add($"Inner side time: {innerChild.ActualElapsedMs:N0}ms."); + } + } + + // Cause/recommendation + var hasParams = stmt.Parameters.Count > 0; + if (hasParams) + details.Add("This may be caused by parameter sniffing — the optimizer chose Nested Loops based on a sniffed value that produced far fewer outer rows."); + else + details.Add("Consider whether a hash or merge join would be more appropriate for this row count."); + + node.Warnings.Add(new PlanWarning + { + WarningType = "Nested Loops High Executions", + Message = string.Join(" ", details), + Severity = innerChild.ActualExecutions > 1000000 + ? PlanWarningSeverity.Critical + : PlanWarningSeverity.Warning + }); + } + // Estimated plans: the optimizer knew the row count and chose Nested Loops + // deliberately — don't second-guess it without actual execution data. + } + + // 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. + if (!cfg.IsRuleDisabled(17) && node.ManyToMany && node.PhysicalOp.Contains("Merge", StringComparison.OrdinalIgnoreCase) && + (!node.HasActualStats || node.ActualLogicalReads > 0)) + { + node.Warnings.Add(new PlanWarning + { + WarningType = "Many-to-Many Merge Join", + Message = node.HasActualStats + ? $"Many-to-many Merge Join — SQL Server created a worktable in TempDB ({node.ActualLogicalReads:N0} logical reads) because both sides have duplicate values in the join columns." + : "Many-to-many Merge Join — SQL Server will create a worktable in TempDB because both sides have duplicate values in the join columns.", + Severity = PlanWarningSeverity.Warning + }); + } + + // Rule 22: Table variables (Object name starts with @) + if (!cfg.IsRuleDisabled(22) && !string.IsNullOrEmpty(node.ObjectName) && + node.ObjectName.StartsWith("@")) + { + var isModificationOp = node.PhysicalOp.Contains("Insert", StringComparison.OrdinalIgnoreCase) + || node.PhysicalOp.Contains("Update", StringComparison.OrdinalIgnoreCase) + || node.PhysicalOp.Contains("Delete", StringComparison.OrdinalIgnoreCase); + + node.Warnings.Add(new PlanWarning + { + WarningType = "Table Variable", + Message = isModificationOp + ? "Modifying a table variable forces the entire plan to run single-threaded. Replace with a #temp table to allow parallel execution." + : "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.", + Severity = isModificationOp ? PlanWarningSeverity.Critical : PlanWarningSeverity.Warning + }); + } + + // Rule 23: Table-valued functions + if (!cfg.IsRuleDisabled(23) && node.LogicalOp == "Table-valued function") + { + var funcName = node.ObjectName ?? node.PhysicalOp; + node.Warnings.Add(new PlanWarning + { + WarningType = "Table-Valued Function", + Message = $"Table-valued function: {funcName}. 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.", + Severity = PlanWarningSeverity.Warning + }); + } + + // 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, + // when an appropriate index could satisfy the request directly. + if (!cfg.IsRuleDisabled(24)) + { + var isTop = node.PhysicalOp == "Top"; + var isTopNSort = node.LogicalOp == "Top N Sort"; + + if ((isTop || isTopNSort) && node.Children.Count > 0) + { + // Walk through pass-through operators below the Top to find the scan + var scanCandidate = node.Children[0]; + while ((scanCandidate.PhysicalOp == "Compute Scalar" || scanCandidate.PhysicalOp == "Parallelism") + && scanCandidate.Children.Count > 0) + scanCandidate = scanCandidate.Children[0]; + + if (IsScanOperator(scanCandidate)) + { + var topLabel = isTopNSort ? "Top N Sort" : "Top"; + var onInner = node.Parent?.PhysicalOp == "Nested Loops" && node.Parent.Children.Count >= 2 + && node.Parent.Children[1] == node; + var innerNote = onInner + ? $" This is on the inner side of Nested Loops (Node {node.Parent!.NodeId}), so the scan repeats for every outer row." + : ""; + var predInfo = !string.IsNullOrEmpty(scanCandidate.Predicate) + ? " The scan has a residual predicate, so it may read many rows before the Top is satisfied." + : ""; + node.Warnings.Add(new PlanWarning + { + WarningType = "Top Above Scan", + Message = $"{topLabel} reads from {FormatNodeRef(scanCandidate)}.{innerNote}{predInfo} An index on the ORDER BY columns could eliminate the scan and sort entirely.", + Severity = onInner ? PlanWarningSeverity.Critical : PlanWarningSeverity.Warning + }); + } + } + } + + // 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 && + (node.PhysicalOp.Contains("Scan") || node.PhysicalOp.Contains("Seek")); + if (!cfg.IsRuleDisabled(26) && isDataAccess && + node.EstimateRowsWithoutRowGoal > 0 && node.EstimateRows > 0 && + node.EstimateRowsWithoutRowGoal > node.EstimateRows) + { + var reduction = node.EstimateRowsWithoutRowGoal / node.EstimateRows; + // Require at least a 2x reduction to be worth mentioning — "1 to 1" or + // tiny floating-point differences that display identically are noise + if (reduction >= 2.0) + { + // If we have actual stats, check whether the row goal prediction was correct. + // When actual rows ≤ the row goal estimate, the optimizer stopped early as planned — benign. + var rowGoalWorked = false; + if (node.HasActualStats) + { + var executions = node.ActualExecutions > 0 ? node.ActualExecutions : 1; + var actualPerExec = (double)node.ActualRows / executions; + rowGoalWorked = actualPerExec <= node.EstimateRows; + } + + if (!rowGoalWorked) + { + // Try to identify the specific row goal cause from the statement text + var cause = IdentifyRowGoalCause(stmt.StatementText); + + node.Warnings.Add(new PlanWarning + { + WarningType = "Row Goal", + Message = $"Row goal active: estimate reduced from {node.EstimateRowsWithoutRowGoal:N0} to {node.EstimateRows:N0} ({reduction:N0}x reduction) due to {cause}. The optimizer chose this plan shape expecting to stop reading early. If the query reads all rows anyway, the plan choice may be suboptimal.", + Severity = PlanWarningSeverity.Info + }); + } + } + } + + // 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 + if (!cfg.IsRuleDisabled(28) && node.PhysicalOp?.Contains("Row Count Spool") == true) + { + var rewinds = node.HasActualStats ? (double)node.ActualRewinds : node.EstimateRewinds; + if (rewinds > 10000 && HasNotInPattern(node, stmt)) + { + node.Warnings.Add(new PlanWarning + { + WarningType = "NOT IN with Nullable Column", + Message = $"Row Count Spool with {rewinds:N0} 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.", + Severity = rewinds > 1_000_000 ? PlanWarningSeverity.Critical : PlanWarningSeverity.Warning + }); + } + } + + // 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)) + foreach (var w in node.Warnings.ToList()) + { + if (w.WarningType == "Implicit Conversion" && w.Message.StartsWith("Seek Plan")) + { + w.Severity = PlanWarningSeverity.Critical; + w.Message = $"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. {w.Message}"; + } + } + + // 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 + // to still surface as top items. Threshold: self-time >= 20% of statement + // elapsed. Only emits if no other warning is already on the node to avoid + // doubling up. The benefit % is just the self-time share. + if (!cfg.IsRuleDisabled(35) && node.HasActualStats && node.Warnings.Count == 0 + && stmt.QueryTimeStats != null && stmt.QueryTimeStats.ElapsedTimeMs > 0) + { + var selfMs = GetOperatorOwnElapsedMs(node); + var pct = (double)selfMs / stmt.QueryTimeStats.ElapsedTimeMs * 100; + if (pct >= 20.0) + { + node.Warnings.Add(new PlanWarning + { + WarningType = "Expensive Operator", + Message = $"{node.PhysicalOp} took {selfMs:N0}ms ({pct:N1}% 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?", + Severity = pct >= 50 ? PlanWarningSeverity.Critical : PlanWarningSeverity.Warning, + MaxBenefitPercent = Math.Round(Math.Min(100.0, pct), 1) + }); + } + } + } +} diff --git a/src/PlanViewer.Core/Services/PlanAnalyzer.Statement.cs b/src/PlanViewer.Core/Services/PlanAnalyzer.Statement.cs new file mode 100644 index 0000000..2c0613f --- /dev/null +++ b/src/PlanViewer.Core/Services/PlanAnalyzer.Statement.cs @@ -0,0 +1,450 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using PlanViewer.Core.Models; + +namespace PlanViewer.Core.Services; + +public static partial class PlanAnalyzer +{ + private static void AnalyzeStatement(PlanStatement stmt, AnalyzerConfig cfg, ServerMetadata? serverMetadata = null) + { + // Rule 3: Serial plan with reason + // Skip: cost < 1 (CTFP is an integer so cost < 1 can never go parallel), + // TRIVIAL optimization (can't go parallel anyway), + // and 0ms actual elapsed time (not worth flagging). + if (!cfg.IsRuleDisabled(3) && !string.IsNullOrEmpty(stmt.NonParallelPlanReason) + && stmt.StatementSubTreeCost >= 1.0 + && stmt.StatementOptmLevel != "TRIVIAL" + && !(stmt.QueryTimeStats != null && stmt.QueryTimeStats.ElapsedTimeMs == 0)) + { + var reason = stmt.NonParallelPlanReason switch + { + // User/config forced serial + "MaxDOPSetToOne" => "MAXDOP is set to 1", + "QueryHintNoParallelSet" => "OPTION (MAXDOP 1) hint forces serial execution", + "ParallelismDisabledByTraceFlag" => "Parallelism disabled by trace flag", + + // Passive — optimizer chose serial, nothing wrong + "EstimatedDOPIsOne" => "Estimated DOP is 1 (the plan's estimated cost was below the cost threshold for parallelism)", + + // Edition/environment limitations + "NoParallelPlansInDesktopOrExpressEdition" => "Express/Desktop edition does not support parallelism", + "NoParallelCreateIndexInNonEnterpriseEdition" => "Parallel index creation requires Enterprise edition", + "NoParallelPlansDuringUpgrade" => "Parallel plans disabled during upgrade", + "NoParallelForPDWCompilation" => "Parallel plans not supported for PDW compilation", + "NoParallelForCloudDBReplication" => "Parallel plans not supported during cloud DB replication", + + // Query constructs that block parallelism (actionable) + "CouldNotGenerateValidParallelPlan" => "Optimizer could not generate a valid parallel plan. Common causes: scalar UDFs, inserts into table variables, certain system functions, or OPTION (MAXDOP 1) hints", + "TSQLUserDefinedFunctionsNotParallelizable" => "T-SQL scalar UDF prevents parallelism. Rewrite as an inline table-valued function, or on SQL Server 2019+ check if the UDF is eligible for automatic inlining", + "CLRUserDefinedFunctionRequiresDataAccess" => "CLR UDF with data access prevents parallelism", + "NonParallelizableIntrinsicFunction" => "Non-parallelizable intrinsic function in the query", + "TableVariableTransactionsDoNotSupportParallelNestedTransaction" => "Table variable transaction prevents parallelism. Consider using a #temp table instead", + "UpdatingWritebackVariable" => "Updating a writeback variable prevents parallelism", + "DMLQueryReturnsOutputToClient" => "DML with OUTPUT clause returning results to client prevents parallelism", + "MixedSerialAndParallelOnlineIndexBuildNotSupported" => "Mixed serial/parallel online index build not supported", + "NoRangesResumableCreate" => "Resumable index create cannot use parallelism for this operation", + + // Cursor limitations + "NoParallelCursorFetchByBookmark" => "Cursor fetch by bookmark cannot use parallelism", + "NoParallelDynamicCursor" => "Dynamic cursors cannot use parallelism", + "NoParallelFastForwardCursor" => "Fast-forward cursors cannot use parallelism", + + // Memory-optimized / natively compiled + "NoParallelForMemoryOptimizedTables" => "Memory-optimized tables do not support parallel plans", + "NoParallelForDmlOnMemoryOptimizedTable" => "DML on memory-optimized tables cannot use parallelism", + "NoParallelForNativelyCompiledModule" => "Natively compiled modules do not support parallelism", + + // Remote queries + "NoParallelWithRemoteQuery" => "Remote queries cannot use parallelism", + "NoRemoteParallelismForMatrix" => "Remote parallelism not available for this query shape", + + _ => stmt.NonParallelPlanReason + }; + + // Actionable: user forced serial, or something in the query blocks parallelism + // that could potentially be rewritten. Info: passive (cost too low) or + // environmental (edition, upgrade, cursor type, memory-optimized). + var isActionable = stmt.NonParallelPlanReason is + "MaxDOPSetToOne" or "QueryHintNoParallelSet" or "ParallelismDisabledByTraceFlag" + or "CouldNotGenerateValidParallelPlan" + or "TSQLUserDefinedFunctionsNotParallelizable" + or "CLRUserDefinedFunctionRequiresDataAccess" + or "NonParallelizableIntrinsicFunction" + or "TableVariableTransactionsDoNotSupportParallelNestedTransaction" + or "UpdatingWritebackVariable" + or "DMLQueryReturnsOutputToClient" + or "NoParallelCursorFetchByBookmark" + or "NoParallelDynamicCursor" + or "NoParallelFastForwardCursor" + or "NoParallelWithRemoteQuery" + or "NoRemoteParallelismForMatrix"; + + // MaxDOPSetToOne needs special handling: check whether the user explicitly + // set MAXDOP 1 in the query text, or if it's a server/db/RG setting. + // SQL Server truncates StatementText at ~4,000 characters in plan XML. + if (stmt.NonParallelPlanReason == "MaxDOPSetToOne") + { + var text = stmt.StatementText ?? ""; + var hasMaxdop1InText = Regex.IsMatch(text, @"MAXDOP\s+1\b", RegexOptions.IgnoreCase); + var isTruncated = text.Length >= 3990; + + if (hasMaxdop1InText) + { + // User explicitly set MAXDOP 1 in the query — warn + stmt.PlanWarnings.Add(new PlanWarning + { + WarningType = "Serial Plan", + Message = "Query running serially: MAXDOP is set to 1 using a query hint.", + Severity = PlanWarningSeverity.Warning + }); + } + else if (isTruncated) + { + // Query text was truncated — can't tell if MAXDOP 1 is in the query + stmt.PlanWarnings.Add(new PlanWarning + { + WarningType = "Serial Plan", + Message = $"Query running serially: {reason}. MAXDOP 1 may be set at the server, database, resource governor, or query level (query text was truncated).", + Severity = PlanWarningSeverity.Info + }); + } + // else: not truncated, no MAXDOP 1 in text — server/db/RG setting, suppress entirely + } + else + { + stmt.PlanWarnings.Add(new PlanWarning + { + WarningType = "Serial Plan", + Message = $"Query running serially: {reason}.", + Severity = isActionable ? PlanWarningSeverity.Warning : PlanWarningSeverity.Info + }); + } + } + + // Rule 9: Memory grant issues (statement-level) + if (!cfg.IsRuleDisabled(9) && stmt.MemoryGrant != null) + { + var grant = stmt.MemoryGrant; + + // Excessive grant — granted far more than actually used + if (grant.GrantedMemoryKB > 0 && grant.MaxUsedMemoryKB > 0) + { + var wasteRatio = (double)grant.GrantedMemoryKB / grant.MaxUsedMemoryKB; + if (wasteRatio >= 10 && grant.GrantedMemoryKB >= 1048576) + { + var grantMB = grant.GrantedMemoryKB / 1024.0; + var usedMB = grant.MaxUsedMemoryKB / 1024.0; + var message = $"Granted {grantMB:N0} MB but only used {usedMB:N0} MB ({wasteRatio:F0}x overestimate). The unused memory is reserved and unavailable to other queries."; + + // Note adaptive joins that chose Nested Loops at runtime — the grant + // was sized for a hash join that never happened. + if (stmt.RootNode != null && HasAdaptiveJoinChoseNestedLoop(stmt.RootNode)) + message += " An adaptive join in this plan executed as a Nested Loop at runtime — the memory grant was sized for the hash join alternative that wasn't used."; + + stmt.PlanWarnings.Add(new PlanWarning + { + WarningType = "Excessive Memory Grant", + Message = message, + Severity = PlanWarningSeverity.Warning + }); + } + } + + // Grant wait — query had to wait for memory + if (grant.GrantWaitTimeMs > 0) + { + stmt.PlanWarnings.Add(new PlanWarning + { + WarningType = "Memory Grant Wait", + Message = $"Query waited {grant.GrantWaitTimeMs:N0}ms for a memory grant before it could start running. Other queries were using all available workspace memory.", + Severity = grant.GrantWaitTimeMs >= 5000 ? PlanWarningSeverity.Critical : PlanWarningSeverity.Warning + }); + } + + // Large memory grant with top consumers + if (grant.GrantedMemoryKB >= 1048576 && stmt.RootNode != null) + { + var consumers = new List(); + FindMemoryConsumers(stmt.RootNode, consumers); + + var grantMB = grant.GrantedMemoryKB / 1024.0; + var guidance = ""; + if (consumers.Count > 0) + { + // Show only the top 3 consumers — listing 20+ is noise + var shown = consumers.Take(3); + var remaining = consumers.Count - 3; + guidance = $" Largest consumers: {string.Join(", ", shown)}"; + if (remaining > 0) + guidance += $", and {remaining} more"; + guidance += "."; + } + + stmt.PlanWarnings.Add(new PlanWarning + { + WarningType = "Large Memory Grant", + Message = $"Query granted {grantMB:F0} MB of memory.{guidance}", + Severity = grantMB >= 4096 ? PlanWarningSeverity.Critical : PlanWarningSeverity.Warning + }); + } + } + + // Rule 18: Compile memory exceeded (early abort) + if (!cfg.IsRuleDisabled(18) && stmt.StatementOptmEarlyAbortReason == "MemoryLimitExceeded") + { + stmt.PlanWarnings.Add(new PlanWarning + { + WarningType = "Compile Memory Exceeded", + Message = "Optimization was aborted early because the compile memory limit was exceeded. The plan is likely suboptimal. Simplify the query by breaking it into smaller steps using #temp tables.", + Severity = PlanWarningSeverity.Critical + }); + } + + // Rule 19: High compile CPU + if (!cfg.IsRuleDisabled(19) && stmt.CompileCPUMs >= 1000) + { + stmt.PlanWarnings.Add(new PlanWarning + { + WarningType = "High Compile CPU", + Message = $"Query took {stmt.CompileCPUMs:N0}ms of CPU just to compile a plan (before any data was read). Simplify the query by breaking it into smaller steps using #temp tables.", + Severity = stmt.CompileCPUMs >= 5000 ? PlanWarningSeverity.Critical : PlanWarningSeverity.Warning + }); + } + + // 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)) + { + stmt.PlanWarnings.Add(new PlanWarning + { + WarningType = "UDF Execution", + Message = $"Scalar UDF cost in this statement: {stmt.QueryUdfElapsedTimeMs:N0}ms elapsed, {stmt.QueryUdfCpuTimeMs:N0}ms 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.", + Severity = stmt.QueryUdfElapsedTimeMs >= 1000 ? PlanWarningSeverity.Critical : PlanWarningSeverity.Warning + }); + } + + // 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. + // Skip statements with cost < 1 (can't go parallel, estimate quality rarely matters). + if (!cfg.IsRuleDisabled(20) && stmt.Parameters.Count > 0 && stmt.StatementSubTreeCost >= 1.0) + { + var unsnifffedParams = stmt.Parameters + .Where(p => string.IsNullOrEmpty(p.CompiledValue)) + .ToList(); + + if (unsnifffedParams.Count > 0) + { + var hasRecompile = stmt.StatementText?.Contains("RECOMPILE", StringComparison.OrdinalIgnoreCase) == true; + if (!hasRecompile) + { + var names = string.Join(", ", unsnifffedParams.Select(p => p.Name)); + stmt.PlanWarnings.Add(new PlanWarning + { + WarningType = "Local Variables", + Message = $"Local variables detected: {names}. 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.", + Severity = PlanWarningSeverity.Warning + }); + } + } + } + + // Rule 21 (CTE referenced multiple times) removed per Joe's #215 feedback: + // for actual plans, SQL Server runtime stats show exactly where time was + // spent, so a statement-text-pattern warning about CTE reuse is guessing. + + // 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)) + { + stmt.PlanWarnings.Add(new PlanWarning + { + WarningType = "Optimize For Unknown", + Message = "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.", + Severity = PlanWarningSeverity.Warning + }); + } + + // 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 + // often delivers a dramatic improvement. + if (!cfg.IsRuleDisabled(36) + && string.Equals(stmt.CursorActualType, "Dynamic", StringComparison.OrdinalIgnoreCase)) + { + var cursorLabel = string.IsNullOrEmpty(stmt.CursorName) ? "Cursor" : $"Cursor \"{stmt.CursorName}\""; + stmt.PlanWarnings.Add(new PlanWarning + { + WarningType = "Dynamic Cursor", + Message = $"{cursorLabel} is a dynamic cursor. Dynamic cursors tolerate underlying data changes between fetches, which prevents many index uses and forces extra work per fetch. If you don't need that semantic, switching to FAST_FORWARD (or STATIC / KEYSET, depending on requirements) typically gives a large performance improvement.", + Severity = PlanWarningSeverity.Warning + }); + } + + // 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: + // https://erikdarling.com/cursor-declarations-that-use-openjson-can-bloat-your-plan-cache/). + if (!cfg.IsRuleDisabled(37) && !string.IsNullOrEmpty(stmt.StatementText)) + { + // DECLARE [qualifier(s)] CURSOR ... FOR + // Flags the declaration if LOCAL isn't among the qualifiers before CURSOR. + var cursorDeclMatch = Regex.Match( + stmt.StatementText, + @"\bDECLARE\s+\w+\s+((?:\w+\s+)*)CURSOR\b", + RegexOptions.IgnoreCase | RegexOptions.Singleline); + if (cursorDeclMatch.Success) + { + var qualifiers = cursorDeclMatch.Groups[1].Value; + if (!Regex.IsMatch(qualifiers, @"\bLOCAL\b", RegexOptions.IgnoreCase)) + { + stmt.PlanWarnings.Add(new PlanWarning + { + WarningType = "Cursor Missing LOCAL", + Message = "CURSOR declaration is missing the LOCAL keyword. Default cursor scope is GLOBAL, which puts the cursor in a shared namespace and can bloat the plan cache (see https://erikdarling.com/cursor-declarations-that-use-openjson-can-bloat-your-plan-cache/). Adding LOCAL is cheap and usually right.", + Severity = PlanWarningSeverity.Warning + }); + } + } + } + + // 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 + && HasBatchModeNode(stmt.RootNode)) + { + // Suppress when the user explicitly set MAXDOP 2 as a query hint — the DOP + // cap is intentional, not the Standard Edition batch-mode limitation. + var hasMaxdop2Hint = !string.IsNullOrEmpty(stmt.StatementText) + && Regex.IsMatch(stmt.StatementText, @"MAXDOP\s+2\b", RegexOptions.IgnoreCase); + + if (!hasMaxdop2Hint) + { + var editionKnown = !string.IsNullOrEmpty(serverMetadata?.Edition); + if (editionKnown + && serverMetadata!.Edition!.Contains("Standard", StringComparison.OrdinalIgnoreCase)) + { + // Server context confirms Standard Edition — check MAXDOP + if (serverMetadata.MaxDop > 2) + { + stmt.PlanWarnings.Add(new PlanWarning + { + WarningType = "Standard Edition DOP Limitation", + Message = $"DOP is limited to 2 because SQL Server Standard Edition caps parallelism at 2 when batch mode operators are present, even though MAXDOP is set to {serverMetadata.MaxDop}. Developer or Enterprise Edition would allow higher DOP in the same conditions.", + Severity = PlanWarningSeverity.Warning + }); + } + } + else if (!editionKnown) + { + // No server context, or edition unknown (e.g. collection failure) — suspect the limitation + stmt.PlanWarnings.Add(new PlanWarning + { + WarningType = "Standard Edition DOP Limitation", + Message = "DOP is limited to 2 and the plan uses batch mode operators. This may be caused by the SQL Server Standard Edition limitation, which caps parallelism at 2 when batch mode is in use. If this server runs Standard Edition, Developer or Enterprise Edition would allow higher DOP.", + Severity = PlanWarningSeverity.Info + }); + } + } + } + + // Rules 25 (Ineffective Parallelism) and 31 (Parallel Wait Bottleneck) were removed. + // 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. + + // Rule 30: Missing index quality evaluation + if (!cfg.IsRuleDisabled(30)) + { + // Detect duplicate suggestions for the same table + var tableSuggestionCount = stmt.MissingIndexes + .GroupBy(mi => $"{mi.Schema}.{mi.Table}", StringComparer.OrdinalIgnoreCase) + .Where(g => g.Count() > 1) + .ToDictionary(g => g.Key, g => g.Count(), StringComparer.OrdinalIgnoreCase); + + foreach (var mi in stmt.MissingIndexes) + { + var keyCount = mi.EqualityColumns.Count + mi.InequalityColumns.Count; + var includeCount = mi.IncludeColumns.Count; + var tableKey = $"{mi.Schema}.{mi.Table}"; + + // Low-impact suggestion (< 25% improvement) + if (mi.Impact < 25) + { + stmt.PlanWarnings.Add(new PlanWarning + { + WarningType = "Low Impact Index", + Message = $"Missing index suggestion for {mi.Table} has only {mi.Impact:F0}% estimated impact. Low-impact indexes add maintenance overhead (insert/update/delete cost) that may not justify the modest query improvement.", + Severity = PlanWarningSeverity.Info + }); + } + + // Wide INCLUDE columns (> 5) + if (includeCount > 5) + { + stmt.PlanWarnings.Add(new PlanWarning + { + WarningType = "Wide Index Suggestion", + Message = $"Missing index suggestion for {mi.Table} has {includeCount} 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.", + Severity = PlanWarningSeverity.Warning + }); + } + // Wide key columns (> 4) + else if (keyCount > 4) + { + stmt.PlanWarnings.Add(new PlanWarning + { + WarningType = "Wide Index Suggestion", + Message = $"Missing index suggestion for {mi.Table} has {keyCount} key columns ({mi.EqualityColumns.Count} equality + {mi.InequalityColumns.Count} inequality). Wide key columns increase index size and maintenance cost. Evaluate whether all key columns are needed for seek predicates.", + Severity = PlanWarningSeverity.Warning + }); + } + + // Multiple suggestions for same table + if (tableSuggestionCount.TryGetValue(tableKey, out var count)) + { + stmt.PlanWarnings.Add(new PlanWarning + { + WarningType = "Duplicate Index Suggestions", + Message = $"{count} missing index suggestions target {mi.Table}. Multiple suggestions for the same table often overlap — consolidate into fewer, broader indexes rather than creating all of them.", + Severity = PlanWarningSeverity.Warning + }); + // Only warn once per table + tableSuggestionCount.Remove(tableKey); + } + } + } + + // 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) + { + var hasTableVar = false; + var isModification = stmt.StatementType is "INSERT" or "UPDATE" or "DELETE" or "MERGE"; + var modifiesTableVar = false; + CheckForTableVariables(stmt.RootNode, isModification, ref hasTableVar, ref modifiesTableVar); + + if (hasTableVar && !modifiesTableVar) + { + stmt.PlanWarnings.Add(new PlanWarning + { + WarningType = "Table Variable", + Message = "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.", + Severity = PlanWarningSeverity.Warning + }); + } + + if (modifiesTableVar) + { + stmt.PlanWarnings.Add(new PlanWarning + { + WarningType = "Table Variable", + Message = "This query modifies a table variable, which forces the entire plan to run single-threaded. SQL Server cannot use parallelism for modifications to table variables. Replace with a #temp table to allow parallel execution.", + Severity = PlanWarningSeverity.Critical + }); + } + } + } +} diff --git a/src/PlanViewer.Core/Services/PlanAnalyzer.Timing.cs b/src/PlanViewer.Core/Services/PlanAnalyzer.Timing.cs new file mode 100644 index 0000000..7d4aaa3 --- /dev/null +++ b/src/PlanViewer.Core/Services/PlanAnalyzer.Timing.cs @@ -0,0 +1,369 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using PlanViewer.Core.Models; + +namespace PlanViewer.Core.Services; + +public static partial class PlanAnalyzer +{ + private static void FindMemoryConsumers(PlanNode node, List consumers) + { + // Collect all consumers first, then sort by row count descending + var raw = new List<(string Label, double Rows)>(); + FindMemoryConsumersRecursive(node, raw); + + foreach (var (label, _) in raw.OrderByDescending(c => c.Rows)) + consumers.Add(label); + } + + private static void FindMemoryConsumersRecursive(PlanNode node, List<(string Label, double Rows)> consumers) + { + if (node.PhysicalOp.Contains("Sort", StringComparison.OrdinalIgnoreCase) && + !node.PhysicalOp.Contains("Spool", StringComparison.OrdinalIgnoreCase)) + { + var rowCount = node.HasActualStats ? node.ActualRows : node.EstimateRows; + var rows = node.HasActualStats + ? $"{node.ActualRows:N0} actual rows" + : $"{node.EstimateRows:N0} estimated rows"; + consumers.Add(($"Sort (Node {node.NodeId}, {rows})", rowCount)); + } + else if (node.PhysicalOp.Contains("Hash", StringComparison.OrdinalIgnoreCase)) + { + var rowCount = node.HasActualStats ? node.ActualRows : node.EstimateRows; + var rows = node.HasActualStats + ? $"{node.ActualRows:N0} actual rows" + : $"{node.EstimateRows:N0} estimated rows"; + consumers.Add(($"Hash Match (Node {node.NodeId}, {rows})", rowCount)); + } + + foreach (var child in node.Children) + FindMemoryConsumersRecursive(child, consumers); + } + + /// + /// Calculates an operator's own elapsed time by subtracting child time. + /// In batch mode, operator times are self-contained (exclusive). + /// In row mode, times are cumulative (include all children below). + /// For parallel plans, we calculate self-time per-thread then take the max, + /// avoiding cross-thread subtraction errors. + /// Exchange operators accumulate downstream wait time (e.g. from spilling + /// children) so their self-time is unreliable — see sql.kiwi/2021/03. + /// + internal static long GetOperatorOwnElapsedMs(PlanNode node) + { + if (node.ActualExecutionMode == "Batch") + return node.ActualElapsedMs; + + // Parallel plan with per-thread data: calculate self-time per thread + if (node.PerThreadStats.Count > 1) + return GetPerThreadOwnElapsed(node); + + // Serial row mode: subtract all direct children's elapsed time + return GetSerialOwnElapsed(node); + } + + /// + /// Per-thread self-time calculation for parallel row mode operators. + /// For each thread: self = parent_elapsed[t] - sum(children_elapsed[t]). + /// Returns max across threads. + /// + private static long GetPerThreadOwnElapsed(PlanNode node) + { + // Build lookup: threadId -> parent elapsed for this node + var parentByThread = new Dictionary(); + foreach (var ts in node.PerThreadStats) + parentByThread[ts.ThreadId] = ts.ActualElapsedMs; + + // Build lookup: threadId -> sum of all direct children's elapsed + var childSumByThread = new Dictionary(); + foreach (var child in node.Children) + { + var childNode = child; + + // Exchange operators have unreliable times — look through to their child + if (child.PhysicalOp == "Parallelism" && child.Children.Count > 0) + childNode = child.Children.OrderByDescending(c => c.ActualElapsedMs).First(); + + foreach (var ts in childNode.PerThreadStats) + { + childSumByThread.TryGetValue(ts.ThreadId, out var existing); + childSumByThread[ts.ThreadId] = existing + ts.ActualElapsedMs; + } + } + + // Self-time per thread = parent - children, take max across threads + var maxSelf = 0L; + foreach (var (threadId, parentMs) in parentByThread) + { + childSumByThread.TryGetValue(threadId, out var childMs); + var self = Math.Max(0, parentMs - childMs); + if (self > maxSelf) maxSelf = self; + } + + return maxSelf; + } + + /// + /// Max per-thread self-CPU for this operator. + /// Parallel: for each thread, self_cpu = thread_cpu - Σ same-thread child cpu; take max. + /// Serial / single-thread: operator_cpu - Σ effective child cpu. + /// Needed for external-wait benefit scoring (Joe's formula). + /// + internal static long GetOperatorMaxThreadOwnCpuMs(PlanNode node) + { + if (!node.HasActualStats || node.ActualCPUMs <= 0) return 0; + + if (node.PerThreadStats.Count > 1) + { + var parentByThread = new Dictionary(); + foreach (var ts in node.PerThreadStats) + parentByThread[ts.ThreadId] = ts.ActualCPUMs; + + var childSumByThread = new Dictionary(); + foreach (var child in node.Children) + { + var childNode = child; + if (child.PhysicalOp == "Parallelism" && child.Children.Count > 0) + childNode = child.Children.OrderByDescending(c => c.ActualCPUMs).First(); + foreach (var ts in childNode.PerThreadStats) + { + childSumByThread.TryGetValue(ts.ThreadId, out var existing); + childSumByThread[ts.ThreadId] = existing + ts.ActualCPUMs; + } + } + + var maxSelf = 0L; + foreach (var (threadId, parentCpu) in parentByThread) + { + childSumByThread.TryGetValue(threadId, out var childCpu); + var self = Math.Max(0, parentCpu - childCpu); + if (self > maxSelf) maxSelf = self; + } + return maxSelf; + } + + // Serial: operator_cpu - Σ effective child cpu + var totalChildCpu = 0L; + foreach (var child in node.Children) + totalChildCpu += GetEffectiveChildCpuMs(child); + return Math.Max(0, node.ActualCPUMs - totalChildCpu); + } + + private static long GetEffectiveChildCpuMs(PlanNode child) + { + if (child.PhysicalOp == "Parallelism" && child.Children.Count > 0) + return child.Children.Max(GetEffectiveChildCpuMs); + if (child.ActualCPUMs > 0) + return child.ActualCPUMs; + if (child.Children.Count == 0) + return 0; + var sum = 0L; + foreach (var grandchild in child.Children) + sum += GetEffectiveChildCpuMs(grandchild); + return sum; + } + + /// + /// Serial row mode self-time: subtract all direct children's effective elapsed. + /// Pass-through operators (Compute Scalar, etc.) don't carry runtime stats — + /// look through them to the first descendant that does. Exchange children + /// use max-child elapsed because exchange times are unreliable. + /// + private static long GetSerialOwnElapsed(PlanNode node) + { + var totalChildElapsed = 0L; + foreach (var child in node.Children) + totalChildElapsed += GetEffectiveChildElapsedMs(child); + + return Math.Max(0, node.ActualElapsedMs - totalChildElapsed); + } + + /// + /// Returns the elapsed time a child contributes to its parent's subtree. + /// Looks through pass-through operators (Compute Scalar, Parallelism exchange) + /// that don't carry reliable runtime stats. + /// + private static long GetEffectiveChildElapsedMs(PlanNode child) + { + // Exchange operators: unreliable times, use max child + if (child.PhysicalOp == "Parallelism" && child.Children.Count > 0) + return child.Children.Max(GetEffectiveChildElapsedMs); + + // Batch mode pipelines — each operator's elapsed stands alone rather than + // rolling up its descendants the way row-mode does. For a parent computing + // self-time above a batch-mode subtree, subtract the whole pipeline's time + // (Joe #215 D1: Parallelism gather-streams above three batch operators). + var mode = child.ActualExecutionMode ?? child.ExecutionMode; + if (mode == "Batch" && child.HasActualStats) + return SumBatchSubtreeElapsedMs(child); + + // Child has its own stats: use them + if (child.ActualElapsedMs > 0) + return child.ActualElapsedMs; + + // No stats (Compute Scalar and similar): look through to descendants + if (child.Children.Count == 0) + return 0; + + var sum = 0L; + foreach (var grandchild in child.Children) + sum += GetEffectiveChildElapsedMs(grandchild); + return sum; + } + + /// + /// Sums ActualElapsedMs across a contiguous batch-mode subtree (stops at + /// Parallelism exchange zone boundaries). Batch operators pipeline — elapsed + /// times are standalone, not cumulative — so summing gives the total work the + /// zone did, which is what a row-mode parent above the zone should subtract + /// to get its own self-time. + /// + private static long SumBatchSubtreeElapsedMs(PlanNode node) + { + long sum = node.ActualElapsedMs; + foreach (var child in node.Children) + { + // Zone boundary — stop summing + if (child.PhysicalOp == "Parallelism") continue; + + var childMode = child.ActualExecutionMode ?? child.ExecutionMode; + if (childMode == "Batch" && child.HasActualStats) + sum += SumBatchSubtreeElapsedMs(child); + else + sum += GetEffectiveChildElapsedMs(child); + } + return sum; + } + + /// + /// Calculates a Parallelism (exchange) operator's own elapsed time. + /// Exchange times are unreliable — they accumulate wait time caused by + /// downstream operators (e.g. spilling sorts). This returns a best-effort + /// value but callers should treat it with caution. + /// + private static long GetParallelismOperatorElapsedMs(PlanNode node) + { + if (node.Children.Count == 0) + return node.ActualElapsedMs; + + if (node.PerThreadStats.Count > 1) + return GetPerThreadOwnElapsed(node); + + var maxChildElapsed = node.Children.Max(c => c.ActualElapsedMs); + return Math.Max(0, node.ActualElapsedMs - maxChildElapsed); + } + + /// + /// Quantifies the cost of work below a Filter operator by summing child subtree metrics. + /// Shows how many rows, reads, and elapsed time were spent producing rows that the + /// Filter then discarded. + /// + private static string QuantifyFilterImpact(PlanNode filterNode) + { + if (filterNode.Children.Count == 0) + return ""; + + var parts = new List(); + + // Rows input vs output — how many rows did the filter discard? + var inputRows = filterNode.Children.Sum(c => c.ActualRows); + if (filterNode.HasActualStats && inputRows > 0 && filterNode.ActualRows < inputRows) + { + var discarded = inputRows - filterNode.ActualRows; + var pct = (double)discarded / inputRows * 100; + parts.Add($"{discarded:N0} of {inputRows:N0} rows discarded ({pct:N0}%)"); + } + + // Logical reads across the entire child subtree + long totalReads = 0; + foreach (var child in filterNode.Children) + totalReads += SumSubtreeReads(child); + if (totalReads > 0) + parts.Add($"{totalReads:N0} logical reads below"); + + // Elapsed time: use the direct child's time (cumulative in row mode, includes its children) + var childElapsed = filterNode.Children.Max(c => c.ActualElapsedMs); + if (childElapsed > 0) + parts.Add($"{childElapsed:N0}ms elapsed below"); + + if (parts.Count == 0) + return ""; + + return string.Join("\n", parts.Select(p => "• " + p)); + } + + /// + /// Detects well-known CE default selectivity guesses by comparing EstimateRows to TableCardinality. + /// Returns a description of the guess pattern, or null if no known pattern matches. + /// + private static string? DetectCeGuess(double estimateRows, double tableCardinality) + { + if (tableCardinality <= 0) return null; + var selectivity = estimateRows / tableCardinality; + + // Known CE guess selectivities with a 2% tolerance band + return selectivity switch + { + >= 0.29 and <= 0.31 => $"matches the 30% equality guess ({selectivity * 100:N1}%)", + >= 0.098 and <= 0.102 => $"matches the 10% inequality guess ({selectivity * 100:N1}%)", + >= 0.088 and <= 0.092 => $"matches the 9% LIKE/BETWEEN guess ({selectivity * 100:N1}%)", + >= 0.155 and <= 0.175 => $"matches the ~16.4% compound predicate guess ({selectivity * 100:N1}%)", + >= 0.009 and <= 0.011 => $"matches the 1% multi-inequality guess ({selectivity * 100:N1}%)", + _ => null + }; + } + + private static long SumSubtreeReads(PlanNode node) + { + long reads = node.ActualLogicalReads; + foreach (var child in node.Children) + reads += SumSubtreeReads(child); + return reads; + } + + /// + /// Builds impact details for a scan node: what % of plan time/cost it represents, + /// and what fraction of rows survived filtering. + /// + private static ScanImpact BuildScanImpactDetails(PlanNode node, PlanStatement stmt) + { + var parts = new List(); + + // % of plan cost + double costPct = 0; + if (stmt.StatementSubTreeCost > 0 && node.EstimatedTotalSubtreeCost > 0) + { + costPct = node.EstimatedTotalSubtreeCost / stmt.StatementSubTreeCost * 100; + if (costPct >= 50) + parts.Add($"This scan is {costPct:N0}% of the plan cost."); + } + + // % of elapsed time (actual plans) + double elapsedPct = 0; + if (node.HasActualStats && node.ActualElapsedMs > 0 && + stmt.QueryTimeStats != null && stmt.QueryTimeStats.ElapsedTimeMs > 0) + { + elapsedPct = (double)node.ActualElapsedMs / stmt.QueryTimeStats.ElapsedTimeMs * 100; + if (elapsedPct >= 50) + parts.Add($"This scan took {elapsedPct:N0}% of elapsed time."); + } + + // Row selectivity: rows returned vs rows read (actual) or vs table cardinality (estimated) + if (node.HasActualStats && node.ActualRowsRead > 0 && node.ActualRows < node.ActualRowsRead) + { + var selectivity = (double)node.ActualRows / node.ActualRowsRead * 100; + if (selectivity < 10) + parts.Add($"Only {selectivity:N3}% of rows survived filtering ({node.ActualRows:N0} of {node.ActualRowsRead:N0})."); + } + else if (!node.HasActualStats && node.TableCardinality > 0 && node.EstimateRows < node.TableCardinality) + { + var selectivity = node.EstimateRows / node.TableCardinality * 100; + if (selectivity < 10) + parts.Add($"Only {selectivity:N1}% of rows estimated to survive filtering."); + } + + return new ScanImpact(costPct, elapsedPct, parts.Count > 0 ? string.Join(" ", parts) : null); + } +} diff --git a/src/PlanViewer.Core/Services/PlanAnalyzer.cs b/src/PlanViewer.Core/Services/PlanAnalyzer.cs index 6591a05..4472936 100644 --- a/src/PlanViewer.Core/Services/PlanAnalyzer.cs +++ b/src/PlanViewer.Core/Services/PlanAnalyzer.cs @@ -10,7 +10,7 @@ namespace PlanViewer.Core.Services; /// Post-parse analysis pass that walks a parsed plan tree and adds warnings /// for common performance anti-patterns. Called after ShowPlanParser.Parse(). /// -public static class PlanAnalyzer +public static partial class PlanAnalyzer { private static readonly Regex FunctionInPredicateRegex = new( @"\b(CONVERT_IMPLICIT|CONVERT|CAST|isnull|coalesce|datepart|datediff|dateadd|year|month|day|upper|lower|ltrim|rtrim|trim|substring|left|right|charindex|replace|len|datalength|abs|floor|ceiling|round|reverse|stuff|format)\s*\(", @@ -76,27 +76,6 @@ public static void Analyze(ParsedPlan plan, AnalyzerConfig? config = null, Serve "Implicit Conversion", }; - private static void MarkLegacyWarnings(PlanStatement stmt) - { - foreach (var w in stmt.PlanWarnings) - { - if (LegacyWarningTypes.Contains(w.WarningType)) - w.IsLegacy = true; - } - if (stmt.RootNode != null) - MarkLegacyWarningsOnTree(stmt.RootNode); - } - - private static void MarkLegacyWarningsOnTree(PlanNode node) - { - foreach (var w in node.Warnings) - { - if (LegacyWarningTypes.Contains(w.WarningType)) - w.IsLegacy = true; - } - foreach (var child in node.Children) - MarkLegacyWarningsOnTree(child); - } // Rule number → WarningType mapping for severity overrides private static readonly Dictionary RuleWarningTypes = new() @@ -128,2111 +107,9 @@ static PlanAnalyzer() WarningTypeToRule[type] = rule; } - private static void ApplySeverityOverrides(ParsedPlan plan, AnalyzerConfig cfg) - { - foreach (var batch in plan.Batches) - { - foreach (var stmt in batch.Statements) - { - foreach (var w in stmt.PlanWarnings) - TryOverrideSeverity(w, cfg); - - if (stmt.RootNode != null) - ApplyOverridesToTree(stmt.RootNode, cfg); - } - } - } - - private static void ApplyOverridesToTree(PlanNode node, AnalyzerConfig cfg) - { - foreach (var w in node.Warnings) - TryOverrideSeverity(w, cfg); - foreach (var child in node.Children) - ApplyOverridesToTree(child, cfg); - } - - private static void TryOverrideSeverity(PlanWarning warning, AnalyzerConfig cfg) - { - // Find the rule number for this warning type (partial match for flexibility) - int? ruleNumber = null; - foreach (var (rule, type) in RuleWarningTypes) - { - if (warning.WarningType.Contains(type, StringComparison.OrdinalIgnoreCase) || - type.Contains(warning.WarningType, StringComparison.OrdinalIgnoreCase)) - { - ruleNumber = rule; - break; - } - } - - if (ruleNumber == null) return; - - var overrideSeverity = cfg.GetSeverityOverride(ruleNumber.Value); - if (overrideSeverity == null) return; - - if (Enum.TryParse(overrideSeverity, ignoreCase: true, out var severity)) - warning.Severity = severity; - } - - private static void AnalyzeStatement(PlanStatement stmt, AnalyzerConfig cfg, ServerMetadata? serverMetadata = null) - { - // Rule 3: Serial plan with reason - // Skip: cost < 1 (CTFP is an integer so cost < 1 can never go parallel), - // TRIVIAL optimization (can't go parallel anyway), - // and 0ms actual elapsed time (not worth flagging). - if (!cfg.IsRuleDisabled(3) && !string.IsNullOrEmpty(stmt.NonParallelPlanReason) - && stmt.StatementSubTreeCost >= 1.0 - && stmt.StatementOptmLevel != "TRIVIAL" - && !(stmt.QueryTimeStats != null && stmt.QueryTimeStats.ElapsedTimeMs == 0)) - { - var reason = stmt.NonParallelPlanReason switch - { - // User/config forced serial - "MaxDOPSetToOne" => "MAXDOP is set to 1", - "QueryHintNoParallelSet" => "OPTION (MAXDOP 1) hint forces serial execution", - "ParallelismDisabledByTraceFlag" => "Parallelism disabled by trace flag", - - // Passive — optimizer chose serial, nothing wrong - "EstimatedDOPIsOne" => "Estimated DOP is 1 (the plan's estimated cost was below the cost threshold for parallelism)", - - // Edition/environment limitations - "NoParallelPlansInDesktopOrExpressEdition" => "Express/Desktop edition does not support parallelism", - "NoParallelCreateIndexInNonEnterpriseEdition" => "Parallel index creation requires Enterprise edition", - "NoParallelPlansDuringUpgrade" => "Parallel plans disabled during upgrade", - "NoParallelForPDWCompilation" => "Parallel plans not supported for PDW compilation", - "NoParallelForCloudDBReplication" => "Parallel plans not supported during cloud DB replication", - - // Query constructs that block parallelism (actionable) - "CouldNotGenerateValidParallelPlan" => "Optimizer could not generate a valid parallel plan. Common causes: scalar UDFs, inserts into table variables, certain system functions, or OPTION (MAXDOP 1) hints", - "TSQLUserDefinedFunctionsNotParallelizable" => "T-SQL scalar UDF prevents parallelism. Rewrite as an inline table-valued function, or on SQL Server 2019+ check if the UDF is eligible for automatic inlining", - "CLRUserDefinedFunctionRequiresDataAccess" => "CLR UDF with data access prevents parallelism", - "NonParallelizableIntrinsicFunction" => "Non-parallelizable intrinsic function in the query", - "TableVariableTransactionsDoNotSupportParallelNestedTransaction" => "Table variable transaction prevents parallelism. Consider using a #temp table instead", - "UpdatingWritebackVariable" => "Updating a writeback variable prevents parallelism", - "DMLQueryReturnsOutputToClient" => "DML with OUTPUT clause returning results to client prevents parallelism", - "MixedSerialAndParallelOnlineIndexBuildNotSupported" => "Mixed serial/parallel online index build not supported", - "NoRangesResumableCreate" => "Resumable index create cannot use parallelism for this operation", - - // Cursor limitations - "NoParallelCursorFetchByBookmark" => "Cursor fetch by bookmark cannot use parallelism", - "NoParallelDynamicCursor" => "Dynamic cursors cannot use parallelism", - "NoParallelFastForwardCursor" => "Fast-forward cursors cannot use parallelism", - - // Memory-optimized / natively compiled - "NoParallelForMemoryOptimizedTables" => "Memory-optimized tables do not support parallel plans", - "NoParallelForDmlOnMemoryOptimizedTable" => "DML on memory-optimized tables cannot use parallelism", - "NoParallelForNativelyCompiledModule" => "Natively compiled modules do not support parallelism", - - // Remote queries - "NoParallelWithRemoteQuery" => "Remote queries cannot use parallelism", - "NoRemoteParallelismForMatrix" => "Remote parallelism not available for this query shape", - - _ => stmt.NonParallelPlanReason - }; - - // Actionable: user forced serial, or something in the query blocks parallelism - // that could potentially be rewritten. Info: passive (cost too low) or - // environmental (edition, upgrade, cursor type, memory-optimized). - var isActionable = stmt.NonParallelPlanReason is - "MaxDOPSetToOne" or "QueryHintNoParallelSet" or "ParallelismDisabledByTraceFlag" - or "CouldNotGenerateValidParallelPlan" - or "TSQLUserDefinedFunctionsNotParallelizable" - or "CLRUserDefinedFunctionRequiresDataAccess" - or "NonParallelizableIntrinsicFunction" - or "TableVariableTransactionsDoNotSupportParallelNestedTransaction" - or "UpdatingWritebackVariable" - or "DMLQueryReturnsOutputToClient" - or "NoParallelCursorFetchByBookmark" - or "NoParallelDynamicCursor" - or "NoParallelFastForwardCursor" - or "NoParallelWithRemoteQuery" - or "NoRemoteParallelismForMatrix"; - - // MaxDOPSetToOne needs special handling: check whether the user explicitly - // set MAXDOP 1 in the query text, or if it's a server/db/RG setting. - // SQL Server truncates StatementText at ~4,000 characters in plan XML. - if (stmt.NonParallelPlanReason == "MaxDOPSetToOne") - { - var text = stmt.StatementText ?? ""; - var hasMaxdop1InText = Regex.IsMatch(text, @"MAXDOP\s+1\b", RegexOptions.IgnoreCase); - var isTruncated = text.Length >= 3990; - - if (hasMaxdop1InText) - { - // User explicitly set MAXDOP 1 in the query — warn - stmt.PlanWarnings.Add(new PlanWarning - { - WarningType = "Serial Plan", - Message = "Query running serially: MAXDOP is set to 1 using a query hint.", - Severity = PlanWarningSeverity.Warning - }); - } - else if (isTruncated) - { - // Query text was truncated — can't tell if MAXDOP 1 is in the query - stmt.PlanWarnings.Add(new PlanWarning - { - WarningType = "Serial Plan", - Message = $"Query running serially: {reason}. MAXDOP 1 may be set at the server, database, resource governor, or query level (query text was truncated).", - Severity = PlanWarningSeverity.Info - }); - } - // else: not truncated, no MAXDOP 1 in text — server/db/RG setting, suppress entirely - } - else - { - stmt.PlanWarnings.Add(new PlanWarning - { - WarningType = "Serial Plan", - Message = $"Query running serially: {reason}.", - Severity = isActionable ? PlanWarningSeverity.Warning : PlanWarningSeverity.Info - }); - } - } - - // Rule 9: Memory grant issues (statement-level) - if (!cfg.IsRuleDisabled(9) && stmt.MemoryGrant != null) - { - var grant = stmt.MemoryGrant; - - // Excessive grant — granted far more than actually used - if (grant.GrantedMemoryKB > 0 && grant.MaxUsedMemoryKB > 0) - { - var wasteRatio = (double)grant.GrantedMemoryKB / grant.MaxUsedMemoryKB; - if (wasteRatio >= 10 && grant.GrantedMemoryKB >= 1048576) - { - var grantMB = grant.GrantedMemoryKB / 1024.0; - var usedMB = grant.MaxUsedMemoryKB / 1024.0; - var message = $"Granted {grantMB:N0} MB but only used {usedMB:N0} MB ({wasteRatio:F0}x overestimate). The unused memory is reserved and unavailable to other queries."; - - // Note adaptive joins that chose Nested Loops at runtime — the grant - // was sized for a hash join that never happened. - if (stmt.RootNode != null && HasAdaptiveJoinChoseNestedLoop(stmt.RootNode)) - message += " An adaptive join in this plan executed as a Nested Loop at runtime — the memory grant was sized for the hash join alternative that wasn't used."; - - stmt.PlanWarnings.Add(new PlanWarning - { - WarningType = "Excessive Memory Grant", - Message = message, - Severity = PlanWarningSeverity.Warning - }); - } - } - - // Grant wait — query had to wait for memory - if (grant.GrantWaitTimeMs > 0) - { - stmt.PlanWarnings.Add(new PlanWarning - { - WarningType = "Memory Grant Wait", - Message = $"Query waited {grant.GrantWaitTimeMs:N0}ms for a memory grant before it could start running. Other queries were using all available workspace memory.", - Severity = grant.GrantWaitTimeMs >= 5000 ? PlanWarningSeverity.Critical : PlanWarningSeverity.Warning - }); - } - - // Large memory grant with top consumers - if (grant.GrantedMemoryKB >= 1048576 && stmt.RootNode != null) - { - var consumers = new List(); - FindMemoryConsumers(stmt.RootNode, consumers); - - var grantMB = grant.GrantedMemoryKB / 1024.0; - var guidance = ""; - if (consumers.Count > 0) - { - // Show only the top 3 consumers — listing 20+ is noise - var shown = consumers.Take(3); - var remaining = consumers.Count - 3; - guidance = $" Largest consumers: {string.Join(", ", shown)}"; - if (remaining > 0) - guidance += $", and {remaining} more"; - guidance += "."; - } - - stmt.PlanWarnings.Add(new PlanWarning - { - WarningType = "Large Memory Grant", - Message = $"Query granted {grantMB:F0} MB of memory.{guidance}", - Severity = grantMB >= 4096 ? PlanWarningSeverity.Critical : PlanWarningSeverity.Warning - }); - } - } - - // Rule 18: Compile memory exceeded (early abort) - if (!cfg.IsRuleDisabled(18) && stmt.StatementOptmEarlyAbortReason == "MemoryLimitExceeded") - { - stmt.PlanWarnings.Add(new PlanWarning - { - WarningType = "Compile Memory Exceeded", - Message = "Optimization was aborted early because the compile memory limit was exceeded. The plan is likely suboptimal. Simplify the query by breaking it into smaller steps using #temp tables.", - Severity = PlanWarningSeverity.Critical - }); - } - - // Rule 19: High compile CPU - if (!cfg.IsRuleDisabled(19) && stmt.CompileCPUMs >= 1000) - { - stmt.PlanWarnings.Add(new PlanWarning - { - WarningType = "High Compile CPU", - Message = $"Query took {stmt.CompileCPUMs:N0}ms of CPU just to compile a plan (before any data was read). Simplify the query by breaking it into smaller steps using #temp tables.", - Severity = stmt.CompileCPUMs >= 5000 ? PlanWarningSeverity.Critical : PlanWarningSeverity.Warning - }); - } - - // 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)) - { - stmt.PlanWarnings.Add(new PlanWarning - { - WarningType = "UDF Execution", - Message = $"Scalar UDF cost in this statement: {stmt.QueryUdfElapsedTimeMs:N0}ms elapsed, {stmt.QueryUdfCpuTimeMs:N0}ms 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.", - Severity = stmt.QueryUdfElapsedTimeMs >= 1000 ? PlanWarningSeverity.Critical : PlanWarningSeverity.Warning - }); - } - - // 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. - // Skip statements with cost < 1 (can't go parallel, estimate quality rarely matters). - if (!cfg.IsRuleDisabled(20) && stmt.Parameters.Count > 0 && stmt.StatementSubTreeCost >= 1.0) - { - var unsnifffedParams = stmt.Parameters - .Where(p => string.IsNullOrEmpty(p.CompiledValue)) - .ToList(); - - if (unsnifffedParams.Count > 0) - { - var hasRecompile = stmt.StatementText?.Contains("RECOMPILE", StringComparison.OrdinalIgnoreCase) == true; - if (!hasRecompile) - { - var names = string.Join(", ", unsnifffedParams.Select(p => p.Name)); - stmt.PlanWarnings.Add(new PlanWarning - { - WarningType = "Local Variables", - Message = $"Local variables detected: {names}. 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.", - Severity = PlanWarningSeverity.Warning - }); - } - } - } - - // Rule 21 (CTE referenced multiple times) removed per Joe's #215 feedback: - // for actual plans, SQL Server runtime stats show exactly where time was - // spent, so a statement-text-pattern warning about CTE reuse is guessing. - - // 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)) - { - stmt.PlanWarnings.Add(new PlanWarning - { - WarningType = "Optimize For Unknown", - Message = "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.", - Severity = PlanWarningSeverity.Warning - }); - } - - // 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 - // often delivers a dramatic improvement. - if (!cfg.IsRuleDisabled(36) - && string.Equals(stmt.CursorActualType, "Dynamic", StringComparison.OrdinalIgnoreCase)) - { - var cursorLabel = string.IsNullOrEmpty(stmt.CursorName) ? "Cursor" : $"Cursor \"{stmt.CursorName}\""; - stmt.PlanWarnings.Add(new PlanWarning - { - WarningType = "Dynamic Cursor", - Message = $"{cursorLabel} is a dynamic cursor. Dynamic cursors tolerate underlying data changes between fetches, which prevents many index uses and forces extra work per fetch. If you don't need that semantic, switching to FAST_FORWARD (or STATIC / KEYSET, depending on requirements) typically gives a large performance improvement.", - Severity = PlanWarningSeverity.Warning - }); - } - - // 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: - // https://erikdarling.com/cursor-declarations-that-use-openjson-can-bloat-your-plan-cache/). - if (!cfg.IsRuleDisabled(37) && !string.IsNullOrEmpty(stmt.StatementText)) - { - // DECLARE [qualifier(s)] CURSOR ... FOR - // Flags the declaration if LOCAL isn't among the qualifiers before CURSOR. - var cursorDeclMatch = Regex.Match( - stmt.StatementText, - @"\bDECLARE\s+\w+\s+((?:\w+\s+)*)CURSOR\b", - RegexOptions.IgnoreCase | RegexOptions.Singleline); - if (cursorDeclMatch.Success) - { - var qualifiers = cursorDeclMatch.Groups[1].Value; - if (!Regex.IsMatch(qualifiers, @"\bLOCAL\b", RegexOptions.IgnoreCase)) - { - stmt.PlanWarnings.Add(new PlanWarning - { - WarningType = "Cursor Missing LOCAL", - Message = "CURSOR declaration is missing the LOCAL keyword. Default cursor scope is GLOBAL, which puts the cursor in a shared namespace and can bloat the plan cache (see https://erikdarling.com/cursor-declarations-that-use-openjson-can-bloat-your-plan-cache/). Adding LOCAL is cheap and usually right.", - Severity = PlanWarningSeverity.Warning - }); - } - } - } - - // 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 - && HasBatchModeNode(stmt.RootNode)) - { - // Suppress when the user explicitly set MAXDOP 2 as a query hint — the DOP - // cap is intentional, not the Standard Edition batch-mode limitation. - var hasMaxdop2Hint = !string.IsNullOrEmpty(stmt.StatementText) - && Regex.IsMatch(stmt.StatementText, @"MAXDOP\s+2\b", RegexOptions.IgnoreCase); - - if (!hasMaxdop2Hint) - { - var editionKnown = !string.IsNullOrEmpty(serverMetadata?.Edition); - if (editionKnown - && serverMetadata!.Edition!.Contains("Standard", StringComparison.OrdinalIgnoreCase)) - { - // Server context confirms Standard Edition — check MAXDOP - if (serverMetadata.MaxDop > 2) - { - stmt.PlanWarnings.Add(new PlanWarning - { - WarningType = "Standard Edition DOP Limitation", - Message = $"DOP is limited to 2 because SQL Server Standard Edition caps parallelism at 2 when batch mode operators are present, even though MAXDOP is set to {serverMetadata.MaxDop}. Developer or Enterprise Edition would allow higher DOP in the same conditions.", - Severity = PlanWarningSeverity.Warning - }); - } - } - else if (!editionKnown) - { - // No server context, or edition unknown (e.g. collection failure) — suspect the limitation - stmt.PlanWarnings.Add(new PlanWarning - { - WarningType = "Standard Edition DOP Limitation", - Message = "DOP is limited to 2 and the plan uses batch mode operators. This may be caused by the SQL Server Standard Edition limitation, which caps parallelism at 2 when batch mode is in use. If this server runs Standard Edition, Developer or Enterprise Edition would allow higher DOP.", - Severity = PlanWarningSeverity.Info - }); - } - } - } - - // Rules 25 (Ineffective Parallelism) and 31 (Parallel Wait Bottleneck) were removed. - // 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. - - // Rule 30: Missing index quality evaluation - if (!cfg.IsRuleDisabled(30)) - { - // Detect duplicate suggestions for the same table - var tableSuggestionCount = stmt.MissingIndexes - .GroupBy(mi => $"{mi.Schema}.{mi.Table}", StringComparer.OrdinalIgnoreCase) - .Where(g => g.Count() > 1) - .ToDictionary(g => g.Key, g => g.Count(), StringComparer.OrdinalIgnoreCase); - - foreach (var mi in stmt.MissingIndexes) - { - var keyCount = mi.EqualityColumns.Count + mi.InequalityColumns.Count; - var includeCount = mi.IncludeColumns.Count; - var tableKey = $"{mi.Schema}.{mi.Table}"; - - // Low-impact suggestion (< 25% improvement) - if (mi.Impact < 25) - { - stmt.PlanWarnings.Add(new PlanWarning - { - WarningType = "Low Impact Index", - Message = $"Missing index suggestion for {mi.Table} has only {mi.Impact:F0}% estimated impact. Low-impact indexes add maintenance overhead (insert/update/delete cost) that may not justify the modest query improvement.", - Severity = PlanWarningSeverity.Info - }); - } - - // Wide INCLUDE columns (> 5) - if (includeCount > 5) - { - stmt.PlanWarnings.Add(new PlanWarning - { - WarningType = "Wide Index Suggestion", - Message = $"Missing index suggestion for {mi.Table} has {includeCount} 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.", - Severity = PlanWarningSeverity.Warning - }); - } - // Wide key columns (> 4) - else if (keyCount > 4) - { - stmt.PlanWarnings.Add(new PlanWarning - { - WarningType = "Wide Index Suggestion", - Message = $"Missing index suggestion for {mi.Table} has {keyCount} key columns ({mi.EqualityColumns.Count} equality + {mi.InequalityColumns.Count} inequality). Wide key columns increase index size and maintenance cost. Evaluate whether all key columns are needed for seek predicates.", - Severity = PlanWarningSeverity.Warning - }); - } - - // Multiple suggestions for same table - if (tableSuggestionCount.TryGetValue(tableKey, out var count)) - { - stmt.PlanWarnings.Add(new PlanWarning - { - WarningType = "Duplicate Index Suggestions", - Message = $"{count} missing index suggestions target {mi.Table}. Multiple suggestions for the same table often overlap — consolidate into fewer, broader indexes rather than creating all of them.", - Severity = PlanWarningSeverity.Warning - }); - // Only warn once per table - tableSuggestionCount.Remove(tableKey); - } - } - } - - // 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) - { - var hasTableVar = false; - var isModification = stmt.StatementType is "INSERT" or "UPDATE" or "DELETE" or "MERGE"; - var modifiesTableVar = false; - CheckForTableVariables(stmt.RootNode, isModification, ref hasTableVar, ref modifiesTableVar); - - if (hasTableVar && !modifiesTableVar) - { - stmt.PlanWarnings.Add(new PlanWarning - { - WarningType = "Table Variable", - Message = "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.", - Severity = PlanWarningSeverity.Warning - }); - } - - if (modifiesTableVar) - { - stmt.PlanWarnings.Add(new PlanWarning - { - WarningType = "Table Variable", - Message = "This query modifies a table variable, which forces the entire plan to run single-threaded. SQL Server cannot use parallelism for modifications to table variables. Replace with a #temp table to allow parallel execution.", - Severity = PlanWarningSeverity.Critical - }); - } - } - } - - private static bool HasBatchModeNode(PlanNode node) - { - var mode = node.ActualExecutionMode ?? node.ExecutionMode; - if (string.Equals(mode, "Batch", StringComparison.OrdinalIgnoreCase)) - return true; - foreach (var child in node.Children) - { - if (HasBatchModeNode(child)) - return true; - } - return false; - } - - private static void CheckForTableVariables(PlanNode node, bool isModification, - ref bool hasTableVar, ref bool modifiesTableVar) - { - if (!string.IsNullOrEmpty(node.ObjectName) && node.ObjectName.StartsWith("@")) - { - hasTableVar = true; - // The modification target is typically an Insert/Update/Delete operator on a table variable - if (isModification && (node.PhysicalOp.Contains("Insert", StringComparison.OrdinalIgnoreCase) - || node.PhysicalOp.Contains("Update", StringComparison.OrdinalIgnoreCase) - || node.PhysicalOp.Contains("Delete", StringComparison.OrdinalIgnoreCase) - || node.PhysicalOp.Contains("Merge", StringComparison.OrdinalIgnoreCase))) - { - modifiesTableVar = true; - } - } - foreach (var child in node.Children) - CheckForTableVariables(child, isModification, ref hasTableVar, ref modifiesTableVar); - } - - private static void AnalyzeNodeTree(PlanNode node, PlanStatement stmt, AnalyzerConfig cfg) - { - AnalyzeNode(node, stmt, cfg); - - foreach (var child in node.Children) - AnalyzeNodeTree(child, stmt, cfg); - } - - private static void AnalyzeNode(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). - // Suppress when the filter's child subtree is trivial (low I/O, fast, cheap). - if (!cfg.IsRuleDisabled(1) && node.PhysicalOp == "Filter" && !string.IsNullOrEmpty(node.Predicate) - && node.Children.Count > 0) - { - // Gate: skip trivial filters based on actual stats or estimated cost - bool isTrivial; - if (node.HasActualStats) - { - long childReads = 0; - foreach (var child in node.Children) - childReads += SumSubtreeReads(child); - var childElapsed = node.Children.Max(c => c.ActualElapsedMs); - isTrivial = childReads < 128 && childElapsed < 10; - } - else - { - var childCost = node.Children.Sum(c => c.EstimatedTotalSubtreeCost); - isTrivial = childCost < 1.0; - } - - if (!isTrivial) - { - var impact = QuantifyFilterImpact(node); - var predicate = Truncate(node.Predicate, 200); - var message = "Filter operator discarding rows late in the plan."; - if (!string.IsNullOrEmpty(impact)) - message += $"\n{impact}"; - message += $"\nPredicate: {predicate}"; - - node.Warnings.Add(new PlanWarning - { - WarningType = "Filter Operator", - Message = message, - Severity = PlanWarningSeverity.Warning - }); - } - } - - // 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)) - { - var message = "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."; - if (!string.IsNullOrEmpty(node.SuggestedIndex)) - message += $"\n\nCreate this index:\n{node.SuggestedIndex}"; - - node.Warnings.Add(new PlanWarning - { - WarningType = "Eager Index Spool", - Message = message, - Severity = PlanWarningSeverity.Critical - }); - } - - // Rule 4: UDF timing — any node spending time in UDFs (actual plans) - if (!cfg.IsRuleDisabled(4) && (node.UdfCpuTimeMs > 0 || node.UdfElapsedTimeMs > 0)) - { - node.Warnings.Add(new PlanWarning - { - WarningType = "UDF Execution", - Message = $"Scalar UDF executing on this operator ({node.UdfElapsedTimeMs:N0}ms elapsed, {node.UdfCpuTimeMs:N0}ms 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.", - Severity = node.UdfElapsedTimeMs >= 1000 ? PlanWarningSeverity.Critical : PlanWarningSeverity.Warning - }); - } - - // 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) - // - A parent join may have chosen the wrong strategy - // - Root nodes with no parent to harm are skipped - // - Nodes whose only parents are Parallelism/Top/Sort (no spill) are skipped - if (!cfg.IsRuleDisabled(5) && node.HasActualStats && node.EstimateRows > 0 - && !node.Lookup) // Key lookups are point lookups (1 row per execution) — per-execution estimate is misleading - { - if (node.ActualRows == 0) - { - // Zero rows with a significant estimate — only warn on operators that - // actually allocate meaningful resources (memory grants for hash/sort/spool). - // Skip Parallelism, Bitmap, Compute Scalar, Filter, Concatenation, etc. - // where 0 rows is just a consequence of upstream filtering. - if (node.EstimateRows >= 100 && AllocatesResources(node)) - { - node.Warnings.Add(new PlanWarning - { - WarningType = "Row Estimate Mismatch", - Message = $"Estimated {node.EstimateRows:N0} rows but actual 0 rows returned. SQL Server allocated resources for rows that never materialized.", - Severity = PlanWarningSeverity.Warning - }); - } - } - else - { - // Compare per-execution actuals to estimates (SQL Server estimates are per-execution) - var executions = node.ActualExecutions > 0 ? node.ActualExecutions : 1; - var actualPerExec = (double)node.ActualRows / executions; - var ratio = actualPerExec / node.EstimateRows; - if (ratio >= 10.0 || ratio <= 0.1) - { - var harm = AssessEstimateHarm(node, ratio); - if (harm != null) - { - var direction = ratio >= 10.0 ? "underestimated" : "overestimated"; - var factor = ratio >= 10.0 ? ratio : 1.0 / ratio; - var actualDisplay = executions > 1 - ? $"Actual {node.ActualRows:N0} ({actualPerExec:N0} rows x {executions:N0} executions)" - : $"Actual {node.ActualRows:N0}"; - node.Warnings.Add(new PlanWarning - { - WarningType = "Row Estimate Mismatch", - Message = $"Estimated {node.EstimateRows:N0} vs {actualDisplay} — {factor:F0}x {direction}. {harm}", - Severity = factor >= 100 ? PlanWarningSeverity.Critical : PlanWarningSeverity.Warning - }); - } - } - } - } - - // 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. - var serialPlanCoversUdf = stmt.NonParallelPlanReason is - "TSQLUserDefinedFunctionsNotParallelizable" - or "CLRUserDefinedFunctionRequiresDataAccess" - or "CouldNotGenerateValidParallelPlan"; - if (!cfg.IsRuleDisabled(6) && !serialPlanCoversUdf) - foreach (var udf in node.ScalarUdfs) - { - var type = udf.IsClrFunction ? "CLR" : "T-SQL"; - node.Warnings.Add(new PlanWarning - { - WarningType = "Scalar UDF", - Message = $"Scalar {type} UDF: {udf.FunctionName}. 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.", - Severity = PlanWarningSeverity.Warning - }); - } - - // 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 - // timing is unreliable but the write count tells the story. - if (!cfg.IsRuleDisabled(7)) - foreach (var w in node.Warnings.ToList()) - { - if (w.SpillDetails == null) - continue; - - var isExchangeSpill = w.SpillDetails.SpillType == "Exchange"; - - if (isExchangeSpill) - { - // Exchange spills: severity based on write count since timing is unreliable - var writes = w.SpillDetails.WritesToTempDb; - if (writes >= 1_000_000) - w.Severity = PlanWarningSeverity.Critical; - else if (writes >= 10_000) - w.Severity = PlanWarningSeverity.Warning; - - // Surface Parallelism operator time when available (actual plans) - if (node.ActualElapsedMs > 0) - { - var operatorMs = GetParallelismOperatorElapsedMs(node); - var stmtMs = stmt.QueryTimeStats?.ElapsedTimeMs ?? 0; - if (stmtMs > 0 && operatorMs > 0) - { - var pct = (double)operatorMs / stmtMs; - w.Message += $" Operator time: {operatorMs:N0}ms ({pct:P0} of statement)."; - } - } - } - else if (node.ActualElapsedMs > 0) - { - // Sort/Hash spills: severity based on operator time percentage - var operatorMs = GetOperatorOwnElapsedMs(node); - var stmtMs = stmt.QueryTimeStats?.ElapsedTimeMs ?? 0; - - if (stmtMs > 0) - { - var pct = (double)operatorMs / stmtMs; - w.Message += $" Operator time: {operatorMs:N0}ms ({pct:P0} of statement)."; - - if (pct >= 0.5) - w.Severity = PlanWarningSeverity.Critical; - else if (pct >= 0.1) - w.Severity = PlanWarningSeverity.Warning; - } - } - } - - // 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 - if (!cfg.IsRuleDisabled(8) && node.PerThreadStats.Count > 1) - { - var workerThreads = node.PerThreadStats.Where(t => t.ThreadId > 0).ToList(); - if (workerThreads.Count < 2) workerThreads = node.PerThreadStats; // fallback - var totalRows = workerThreads.Sum(t => t.ActualRows); - var minRowsForSkew = workerThreads.Count * 1000; - if (totalRows >= minRowsForSkew) - { - var maxThread = workerThreads.OrderByDescending(t => t.ActualRows).First(); - var skewRatio = (double)maxThread.ActualRows / totalRows; - // At DOP 2, a 60/40 split is normal — use higher threshold - 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 severity = PlanWarningSeverity.Warning; - - // Batch mode sorts produce all output on a single thread by design - // unless their parent is a batch mode Window Aggregate - if (node.PhysicalOp == "Sort" - && (node.ActualExecutionMode ?? node.ExecutionMode) == "Batch" - && node.Parent?.PhysicalOp != "Window Aggregate") - { - message += " Batch mode sorts produce all output rows on a single thread by design, unless feeding a batch mode Window Aggregate."; - severity = PlanWarningSeverity.Info; - } - else - { - // Add practical context — skew is often hard to fix - message += " 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."; - } - - node.Warnings.Add(new PlanWarning - { - WarningType = "Parallel Skew", - Message = message, - Severity = severity - }); - } - } - } - - // 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)) - { - var message = "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."; - if (!string.IsNullOrEmpty(node.Predicate)) - message += $" Predicate: {Truncate(node.Predicate, 200)}"; - - node.Warnings.Add(new PlanWarning - { - WarningType = "RID Lookup", - Message = message, - Severity = PlanWarningSeverity.Warning - }); - } - else if (!cfg.IsRuleDisabled(10) && node.Lookup) - { - var lookupMsg = "Key Lookup — SQL Server found rows via a nonclustered index but had to go back to the clustered index for additional columns."; - - // Show what columns the lookup is fetching - if (!string.IsNullOrEmpty(node.OutputColumns)) - lookupMsg += $"\nColumns fetched: {Truncate(node.OutputColumns, 200)}"; - - // Only call out the predicate if it actually filters rows - if (!string.IsNullOrEmpty(node.Predicate)) - { - var predicateFilters = node.HasActualStats && node.ActualExecutions > 0 - && node.ActualRows < node.ActualExecutions; - if (predicateFilters) - lookupMsg += $"\nResidual predicate (filtered {node.ActualExecutions - node.ActualRows:N0} rows): {Truncate(node.Predicate, 200)}"; - } - - lookupMsg += "\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."; - - node.Warnings.Add(new PlanWarning - { - WarningType = "Key Lookup", - Message = lookupMsg, - Severity = PlanWarningSeverity.Critical - }); - } - - // 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); - if (nonSargableReason != null) - { - var nonSargableAdvice = nonSargableReason switch - { - "Implicit conversion (CONVERT_IMPLICIT)" => - "Implicit conversion (CONVERT_IMPLICIT) prevents an index seek. Match the parameter or variable data type to the column data type.", - "ISNULL/COALESCE wrapping column" => - "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\".", - "Leading wildcard LIKE pattern" => - "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.", - "CASE expression in predicate" => - "CASE expression in a predicate prevents an index seek. Rewrite using separate WHERE clauses combined with OR, or split into multiple queries.", - _ when nonSargableReason.StartsWith("Function call") => - $"{nonSargableReason} 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.", - _ => - $"{nonSargableReason} prevents an index seek, forcing a scan." - }; - - node.Warnings.Add(new PlanWarning - { - WarningType = "Non-SARGable Predicate", - Message = $"{nonSargableAdvice}\nPredicate: {Truncate(node.Predicate!, 200)}", - Severity = PlanWarningSeverity.Warning - }); - } - - // 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) && - !IsProbeOnly(node.Predicate) && !(node.HasActualStats && node.ActualExecutions == 0)) - { - var displayPredicate = StripProbeExpressions(node.Predicate); - var details = BuildScanImpactDetails(node, stmt); - var severity = PlanWarningSeverity.Warning; - - // Elevate to Critical if the scan dominates the plan - if (details.CostPct >= 90 || details.ElapsedPct >= 90) - severity = PlanWarningSeverity.Critical; - - var message = "Scan with residual predicate — SQL Server is reading every row and filtering after the fact."; - if (!string.IsNullOrEmpty(details.Summary)) - message += $" {details.Summary}"; - - // #215 E2: if the statement is executing a dynamic cursor, that's usually - // the reason an index didn't get used. Call it out so the user looks there - // first rather than hunting for a missing index. - var isDynamicCursor = string.Equals(stmt.CursorActualType, "Dynamic", - StringComparison.OrdinalIgnoreCase); - if (isDynamicCursor) - message += " This query is running inside a dynamic cursor, which can prevent index usage; changing the cursor type (FAST_FORWARD / STATIC / KEYSET) often fixes scans like this without any indexing change."; - else - message += " Check that you have appropriate indexes."; - - // I/O waits specifically confirm the scan is hitting disk — elevate - if (HasSignificantIoWaits(stmt.WaitStats) && details.CostPct >= 50 - && severity != PlanWarningSeverity.Critical) - severity = PlanWarningSeverity.Critical; - - message += $"\nPredicate: {Truncate(displayPredicate, 200)}"; - - node.Warnings.Add(new PlanWarning - { - WarningType = "Scan With Predicate", - Message = message, - Severity = severity - }); - } - - // 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. - // With accurate estimates, it would likely seek instead. - if (!cfg.IsRuleDisabled(32) && node.HasActualStats && IsRowstoreScan(node) - && node.EstimateRows > 0 && node.ActualRows >= 0 && node.ActualRowsRead > 0) - { - var impact = BuildScanImpactDetails(node, stmt); - var overestimateRatio = node.EstimateRows / Math.Max(1.0, node.ActualRows); - var selectivity = (double)node.ActualRows / node.ActualRowsRead; - - // Fire when: scan is >= 50% of plan, estimate is >= 10x actual, and < 10% selectivity - if ((impact.CostPct >= 50 || impact.ElapsedPct >= 50) - && overestimateRatio >= 10.0 - && selectivity < 0.10) - { - node.Warnings.Add(new PlanWarning - { - WarningType = "Scan Cardinality Misestimate", - Message = $"Estimated {node.EstimateRows:N0} rows but only {node.ActualRows:N0} returned ({selectivity * 100:N3}% of {node.ActualRowsRead:N0} rows read). " + - $"The {overestimateRatio:N0}x overestimate likely caused the optimizer to choose a scan instead of a seek. " + - $"An index on the predicate columns could dramatically reduce I/O.", - Severity = PlanWarningSeverity.Critical - }); - } - } - - // 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%)), - // 1% (multi-inequality). On large tables, these guesses can hide the need for an index. - if (!cfg.IsRuleDisabled(33) && !node.HasActualStats && IsRowstoreScan(node) - && node.TableCardinality >= 100_000 && node.EstimateRows > 0 - && !string.IsNullOrEmpty(node.Predicate)) - { - var impact = BuildScanImpactDetails(node, stmt); - if (impact.CostPct >= 50) - { - var guessDesc = DetectCeGuess(node.EstimateRows, node.TableCardinality); - if (guessDesc != null) - { - node.Warnings.Add(new PlanWarning - { - WarningType = "Estimated Plan CE Guess", - Message = $"Estimated {node.EstimateRows:N0} rows from {node.TableCardinality:N0} row table — {guessDesc}. " + - $"The optimizer may be using a default guess instead of accurate statistics. " + - $"If actual selectivity is much lower, an index on the predicate columns could help significantly.", - Severity = PlanWarningSeverity.Warning - }); - } - } - } - - // 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 - // cover the query with far less I/O. For analytical workloads, columnstore may - // be a better fit. - var isBareScanCandidate = (node.PhysicalOp == "Clustered Index Scan" || node.PhysicalOp == "Table Scan") - && !node.Lookup - && string.IsNullOrEmpty(node.Predicate) - && !string.IsNullOrEmpty(node.OutputColumns); - if (!cfg.IsRuleDisabled(34) && isBareScanCandidate) - { - var colCount = node.OutputColumns!.Split(',').Length; - var isSignificant = node.HasActualStats - ? GetOperatorOwnElapsedMs(node) > 0 - : node.CostPercent >= 20; - - if (isSignificant) - { - var scanKind = node.PhysicalOp == "Clustered Index Scan" - ? "Clustered index scan" - : "Heap table scan"; - - if (colCount <= 3) - { - // Narrow output: a nonclustered rowstore index can cover this cheaply. - var indexAdvice = node.PhysicalOp == "Clustered Index Scan" - ? "Consider a nonclustered index on the output columns (as key or INCLUDE) so SQL Server can read a narrower structure." - : "Consider a clustered or nonclustered index on the output columns so SQL Server can read a narrower structure."; - - node.Warnings.Add(new PlanWarning - { - WarningType = "Bare Scan", - Message = $"{scanKind} reads the full table with no predicate, outputting {colCount} column(s): {Truncate(node.OutputColumns, 200)}. {indexAdvice} For analytical workloads, a columnstore index may be a better fit.", - Severity = PlanWarningSeverity.Warning - }); - } - else - { - // Wider output: rowstore NC index isn't a great fit (would have to - // carry too many columns), but columnstore doesn't care about column - // count. Suggest it for analytical / aggregate-style workloads. - node.Warnings.Add(new PlanWarning - { - WarningType = "Bare Scan", - Message = $"{scanKind} reads the full table with no predicate, outputting {colCount} 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.", - Severity = PlanWarningSeverity.Warning - }); - } - } - } - - // Rule 13: Mismatched data types (GetRangeWithMismatchedTypes / GetRangeThroughConvert) - if (!cfg.IsRuleDisabled(13) && node.PhysicalOp == "Compute Scalar" && !string.IsNullOrEmpty(node.DefinedValues)) - { - var hasMismatch = node.DefinedValues.Contains("GetRangeWithMismatchedTypes", StringComparison.OrdinalIgnoreCase); - var hasConvert = node.DefinedValues.Contains("GetRangeThroughConvert", StringComparison.OrdinalIgnoreCase); - - if (hasMismatch || hasConvert) - { - var reason = hasMismatch - ? "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." - : "CONVERT/CAST wrapping a column in the predicate. SQL Server is converting every row to compare, preventing index seeks. Match your data types — convert the parameter/literal instead of the column."; - - node.Warnings.Add(new PlanWarning - { - WarningType = "Data Type Mismatch", - Message = reason, - Severity = PlanWarningSeverity.Warning - }); - } - } - - // 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) - // so rebind/rewind counts are unreliable. See https://www.sql.kiwi/2025/02/lazy-index-spool/ - if (!cfg.IsRuleDisabled(14) && node.LogicalOp == "Lazy Spool" - && !node.PhysicalOp.Contains("Index", StringComparison.OrdinalIgnoreCase)) - { - var rebinds = node.HasActualStats ? (double)node.ActualRebinds : node.EstimateRebinds; - var rewinds = node.HasActualStats ? (double)node.ActualRewinds : node.EstimateRewinds; - var source = node.HasActualStats ? "actual" : "estimated"; - if (rebinds > 100 && rewinds < rebinds * 5) - { - var severity = rewinds < rebinds - ? PlanWarningSeverity.Critical - : PlanWarningSeverity.Warning; + /// + private record ScanImpact(double CostPct, double ElapsedPct, string? Summary); - var ratio = rewinds > 0 - ? $"{rewinds / rebinds:F1}x rewinds (cache hits) per rebind (cache miss)" - : "no rewinds (cache hits) at all"; - node.Warnings.Add(new PlanWarning - { - WarningType = "Lazy Spool Ineffective", - Message = $"Lazy spool has low cache hit ratio ({source}): {rebinds:N0} rebinds (cache misses), {rewinds:N0} rewinds (cache hits) — {ratio}. The spool is caching results but rarely reusing them, adding overhead for no benefit.", - Severity = severity - }); - } - } - - // 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") - { - var constantScanBranches = node.Children - .Count(c => c.PhysicalOp == "Constant Scan" || - (c.PhysicalOp == "Compute Scalar" && - c.Children.Any(gc => gc.PhysicalOp == "Constant Scan"))); - - if (constantScanBranches >= 2 && IsOrExpansionChain(node)) - { - node.Warnings.Add(new PlanWarning - { - WarningType = "Join OR Clause", - Message = $"OR in a join predicate. SQL Server rewrote the OR as {constantScanBranches} 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\".", - Severity = PlanWarningSeverity.Warning - }); - } - } - - // 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" && - node.LogicalOp.Contains("Join", StringComparison.OrdinalIgnoreCase) && - !node.IsAdaptive && - node.Children.Count >= 2) - { - var outerChild = node.Children[0]; - var innerChild = node.Children[1]; - - if (innerChild.HasActualStats && innerChild.ActualExecutions > 100000) - { - var dop = stmt.DegreeOfParallelism > 0 ? stmt.DegreeOfParallelism : 1; - var details = new List(); - - // Core fact - details.Add($"Nested Loops inner side executed {innerChild.ActualExecutions:N0} times (DOP {dop})."); - - // Outer side estimate mismatch — explains WHY the optimizer chose NL - if (outerChild.HasActualStats && outerChild.EstimateRows > 0) - { - var outerExecs = outerChild.ActualExecutions > 0 ? outerChild.ActualExecutions : 1; - var outerActualPerExec = (double)outerChild.ActualRows / outerExecs; - var outerRatio = outerActualPerExec / outerChild.EstimateRows; - if (outerRatio >= 10.0) - { - details.Add($"Outer side: estimated {outerChild.EstimateRows:N0} rows, actual {outerActualPerExec:N0} ({outerRatio:F0}x underestimate). The optimizer chose Nested Loops expecting far fewer iterations."); - } - } - - // Inner side cost — reads and time spent doing the repeated work - long innerReads = SumSubtreeReads(innerChild); - if (innerReads > 0) - details.Add($"Inner side total: {innerReads:N0} logical reads."); - - if (innerChild.ActualElapsedMs > 0) - { - var stmtMs = stmt.QueryTimeStats?.ElapsedTimeMs ?? 0; - if (stmtMs > 0) - { - var pct = (double)innerChild.ActualElapsedMs / stmtMs * 100; - details.Add($"Inner side time: {innerChild.ActualElapsedMs:N0}ms ({pct:N0}% of statement)."); - } - else - { - details.Add($"Inner side time: {innerChild.ActualElapsedMs:N0}ms."); - } - } - - // Cause/recommendation - var hasParams = stmt.Parameters.Count > 0; - if (hasParams) - details.Add("This may be caused by parameter sniffing — the optimizer chose Nested Loops based on a sniffed value that produced far fewer outer rows."); - else - details.Add("Consider whether a hash or merge join would be more appropriate for this row count."); - - node.Warnings.Add(new PlanWarning - { - WarningType = "Nested Loops High Executions", - Message = string.Join(" ", details), - Severity = innerChild.ActualExecutions > 1000000 - ? PlanWarningSeverity.Critical - : PlanWarningSeverity.Warning - }); - } - // Estimated plans: the optimizer knew the row count and chose Nested Loops - // deliberately — don't second-guess it without actual execution data. - } - - // 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. - if (!cfg.IsRuleDisabled(17) && node.ManyToMany && node.PhysicalOp.Contains("Merge", StringComparison.OrdinalIgnoreCase) && - (!node.HasActualStats || node.ActualLogicalReads > 0)) - { - node.Warnings.Add(new PlanWarning - { - WarningType = "Many-to-Many Merge Join", - Message = node.HasActualStats - ? $"Many-to-many Merge Join — SQL Server created a worktable in TempDB ({node.ActualLogicalReads:N0} logical reads) because both sides have duplicate values in the join columns." - : "Many-to-many Merge Join — SQL Server will create a worktable in TempDB because both sides have duplicate values in the join columns.", - Severity = PlanWarningSeverity.Warning - }); - } - - // Rule 22: Table variables (Object name starts with @) - if (!cfg.IsRuleDisabled(22) && !string.IsNullOrEmpty(node.ObjectName) && - node.ObjectName.StartsWith("@")) - { - var isModificationOp = node.PhysicalOp.Contains("Insert", StringComparison.OrdinalIgnoreCase) - || node.PhysicalOp.Contains("Update", StringComparison.OrdinalIgnoreCase) - || node.PhysicalOp.Contains("Delete", StringComparison.OrdinalIgnoreCase); - - node.Warnings.Add(new PlanWarning - { - WarningType = "Table Variable", - Message = isModificationOp - ? "Modifying a table variable forces the entire plan to run single-threaded. Replace with a #temp table to allow parallel execution." - : "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.", - Severity = isModificationOp ? PlanWarningSeverity.Critical : PlanWarningSeverity.Warning - }); - } - - // Rule 23: Table-valued functions - if (!cfg.IsRuleDisabled(23) && node.LogicalOp == "Table-valued function") - { - var funcName = node.ObjectName ?? node.PhysicalOp; - node.Warnings.Add(new PlanWarning - { - WarningType = "Table-Valued Function", - Message = $"Table-valued function: {funcName}. 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.", - Severity = PlanWarningSeverity.Warning - }); - } - - // 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, - // when an appropriate index could satisfy the request directly. - if (!cfg.IsRuleDisabled(24)) - { - var isTop = node.PhysicalOp == "Top"; - var isTopNSort = node.LogicalOp == "Top N Sort"; - - if ((isTop || isTopNSort) && node.Children.Count > 0) - { - // Walk through pass-through operators below the Top to find the scan - var scanCandidate = node.Children[0]; - while ((scanCandidate.PhysicalOp == "Compute Scalar" || scanCandidate.PhysicalOp == "Parallelism") - && scanCandidate.Children.Count > 0) - scanCandidate = scanCandidate.Children[0]; - - if (IsScanOperator(scanCandidate)) - { - var topLabel = isTopNSort ? "Top N Sort" : "Top"; - var onInner = node.Parent?.PhysicalOp == "Nested Loops" && node.Parent.Children.Count >= 2 - && node.Parent.Children[1] == node; - var innerNote = onInner - ? $" This is on the inner side of Nested Loops (Node {node.Parent!.NodeId}), so the scan repeats for every outer row." - : ""; - var predInfo = !string.IsNullOrEmpty(scanCandidate.Predicate) - ? " The scan has a residual predicate, so it may read many rows before the Top is satisfied." - : ""; - node.Warnings.Add(new PlanWarning - { - WarningType = "Top Above Scan", - Message = $"{topLabel} reads from {FormatNodeRef(scanCandidate)}.{innerNote}{predInfo} An index on the ORDER BY columns could eliminate the scan and sort entirely.", - Severity = onInner ? PlanWarningSeverity.Critical : PlanWarningSeverity.Warning - }); - } - } - } - - // 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 && - (node.PhysicalOp.Contains("Scan") || node.PhysicalOp.Contains("Seek")); - if (!cfg.IsRuleDisabled(26) && isDataAccess && - node.EstimateRowsWithoutRowGoal > 0 && node.EstimateRows > 0 && - node.EstimateRowsWithoutRowGoal > node.EstimateRows) - { - var reduction = node.EstimateRowsWithoutRowGoal / node.EstimateRows; - // Require at least a 2x reduction to be worth mentioning — "1 to 1" or - // tiny floating-point differences that display identically are noise - if (reduction >= 2.0) - { - // If we have actual stats, check whether the row goal prediction was correct. - // When actual rows ≤ the row goal estimate, the optimizer stopped early as planned — benign. - var rowGoalWorked = false; - if (node.HasActualStats) - { - var executions = node.ActualExecutions > 0 ? node.ActualExecutions : 1; - var actualPerExec = (double)node.ActualRows / executions; - rowGoalWorked = actualPerExec <= node.EstimateRows; - } - - if (!rowGoalWorked) - { - // Try to identify the specific row goal cause from the statement text - var cause = IdentifyRowGoalCause(stmt.StatementText); - - node.Warnings.Add(new PlanWarning - { - WarningType = "Row Goal", - Message = $"Row goal active: estimate reduced from {node.EstimateRowsWithoutRowGoal:N0} to {node.EstimateRows:N0} ({reduction:N0}x reduction) due to {cause}. The optimizer chose this plan shape expecting to stop reading early. If the query reads all rows anyway, the plan choice may be suboptimal.", - Severity = PlanWarningSeverity.Info - }); - } - } - } - - // 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 - if (!cfg.IsRuleDisabled(28) && node.PhysicalOp?.Contains("Row Count Spool") == true) - { - var rewinds = node.HasActualStats ? (double)node.ActualRewinds : node.EstimateRewinds; - if (rewinds > 10000 && HasNotInPattern(node, stmt)) - { - node.Warnings.Add(new PlanWarning - { - WarningType = "NOT IN with Nullable Column", - Message = $"Row Count Spool with {rewinds:N0} 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.", - Severity = rewinds > 1_000_000 ? PlanWarningSeverity.Critical : PlanWarningSeverity.Warning - }); - } - } - - // 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)) - foreach (var w in node.Warnings.ToList()) - { - if (w.WarningType == "Implicit Conversion" && w.Message.StartsWith("Seek Plan")) - { - w.Severity = PlanWarningSeverity.Critical; - w.Message = $"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. {w.Message}"; - } - } - - // 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 - // to still surface as top items. Threshold: self-time >= 20% of statement - // elapsed. Only emits if no other warning is already on the node to avoid - // doubling up. The benefit % is just the self-time share. - if (!cfg.IsRuleDisabled(35) && node.HasActualStats && node.Warnings.Count == 0 - && stmt.QueryTimeStats != null && stmt.QueryTimeStats.ElapsedTimeMs > 0) - { - var selfMs = GetOperatorOwnElapsedMs(node); - var pct = (double)selfMs / stmt.QueryTimeStats.ElapsedTimeMs * 100; - if (pct >= 20.0) - { - node.Warnings.Add(new PlanWarning - { - WarningType = "Expensive Operator", - Message = $"{node.PhysicalOp} took {selfMs:N0}ms ({pct:N1}% 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?", - Severity = pct >= 50 ? PlanWarningSeverity.Critical : PlanWarningSeverity.Warning, - MaxBenefitPercent = Math.Round(Math.Min(100.0, pct), 1) - }); - } - } - } - - /// - /// Detects the NOT IN with nullable column pattern: statement has NOT IN, - /// and a nearby Nested Loops Anti Semi Join has an IS NULL residual predicate. - /// Checks ancestors and their children (siblings of ancestors) since the IS NULL - /// predicate may be on a sibling Anti Semi Join rather than a direct parent. - /// - private static bool HasNotInPattern(PlanNode spoolNode, PlanStatement stmt) - { - // Check statement text for NOT IN - if (string.IsNullOrEmpty(stmt.StatementText) || - !Regex.IsMatch(stmt.StatementText, @"\bNOT\s+IN\b", RegexOptions.IgnoreCase)) - return false; - - // Walk up the tree checking ancestors and their children - var parent = spoolNode.Parent; - while (parent != null) - { - if (IsAntiSemiJoinWithIsNull(parent)) - return true; - - // Check siblings: the IS NULL predicate may be on a sibling Anti Semi Join - // (e.g. outer NL Anti Semi Join has two children: inner NL Anti Semi Join + Row Count Spool) - foreach (var sibling in parent.Children) - { - if (sibling != spoolNode && IsAntiSemiJoinWithIsNull(sibling)) - return true; - } - - parent = parent.Parent; - } - - return false; - } - - private static bool IsAntiSemiJoinWithIsNull(PlanNode node) => - node.PhysicalOp == "Nested Loops" && - node.LogicalOp.Contains("Anti Semi", StringComparison.OrdinalIgnoreCase) && - !string.IsNullOrEmpty(node.Predicate) && - node.Predicate.Contains("IS NULL", StringComparison.OrdinalIgnoreCase); - - /// - /// Returns true for rowstore scan operators (Index Scan, Clustered Index Scan, - /// Table Scan). Excludes columnstore scans, spools, and constant scans. - /// - private static bool IsRowstoreScan(PlanNode node) - { - return node.PhysicalOp.Contains("Scan", StringComparison.OrdinalIgnoreCase) && - !node.PhysicalOp.Contains("Spool", StringComparison.OrdinalIgnoreCase) && - !node.PhysicalOp.Contains("Constant", StringComparison.OrdinalIgnoreCase) && - !node.PhysicalOp.Contains("Columnstore", StringComparison.OrdinalIgnoreCase); - } - - /// - /// Returns true when the predicate contains ONLY PROBE() bitmap filter(s) - /// with no real residual predicate. PROBE alone is a bitmap filter pushed - /// down from a hash join — not interesting by itself. If a real predicate - /// exists alongside PROBE (e.g. "[col]=(1) AND PROBE(...)"), returns false. - /// - private static bool IsProbeOnly(string predicate) - { - // Strip all PROBE(...) expressions — PROBE args can contain nested parens - var stripped = Regex.Replace(predicate, @"PROBE\s*\([^()]*(?:\([^()]*\)[^()]*)*\)", "", - RegexOptions.IgnoreCase).Trim(); - - // Remove leftover AND/OR connectors and whitespace - stripped = Regex.Replace(stripped, @"\b(AND|OR)\b", "", RegexOptions.IgnoreCase).Trim(); - - // If nothing meaningful remains, it was PROBE-only - return stripped.Length == 0; - } - - /// - /// Strips PROBE(...) bitmap filter expressions from a predicate for display, - /// leaving only the real residual predicate columns. - /// - private static string StripProbeExpressions(string predicate) - { - var stripped = Regex.Replace(predicate, @"\s*AND\s+PROBE\s*\([^()]*(?:\([^()]*\)[^()]*)*\)", "", - RegexOptions.IgnoreCase); - stripped = Regex.Replace(stripped, @"PROBE\s*\([^()]*(?:\([^()]*\)[^()]*)*\)\s*AND\s+", "", - RegexOptions.IgnoreCase); - stripped = Regex.Replace(stripped, @"PROBE\s*\([^()]*(?:\([^()]*\)[^()]*)*\)", "", - RegexOptions.IgnoreCase); - return stripped.Trim(); - } - - /// - /// Returns true for any scan operator including columnstore. - /// Excludes spools and constant scans. - /// - private static bool IsScanOperator(PlanNode node) - { - return node.PhysicalOp.Contains("Scan", StringComparison.OrdinalIgnoreCase) && - !node.PhysicalOp.Contains("Spool", StringComparison.OrdinalIgnoreCase) && - !node.PhysicalOp.Contains("Constant", StringComparison.OrdinalIgnoreCase); - } - - - /// - /// Detects non-SARGable patterns in scan predicates. - /// Returns a description of the issue, or null if the predicate is fine. - /// - private static string? DetectNonSargablePredicate(PlanNode node) - { - if (string.IsNullOrEmpty(node.Predicate)) - return null; - - // Only check rowstore scan operators — columnstore is designed to be scanned - if (!IsRowstoreScan(node)) - return null; - - var predicate = node.Predicate; - - // CASE expression in predicate — check first because CASE bodies - // often contain CONVERT_IMPLICIT that isn't the root cause - if (CaseInPredicateRegex.IsMatch(predicate)) - return "CASE expression in predicate"; - - // CONVERT_IMPLICIT — most common non-SARGable pattern - if (predicate.Contains("CONVERT_IMPLICIT", StringComparison.OrdinalIgnoreCase)) - return "Implicit conversion (CONVERT_IMPLICIT)"; - - // ISNULL / COALESCE wrapping column - if (Regex.IsMatch(predicate, @"\b(isnull|coalesce)\s*\(", RegexOptions.IgnoreCase)) - return "ISNULL/COALESCE wrapping column"; - - // Common function calls on columns — but only if the function wraps a column, - // not a parameter/variable. Split on comparison operators to check which side - // the function is on. Predicate format: [db].[schema].[table].[col]>func(...) - var funcMatch = FunctionInPredicateRegex.Match(predicate); - if (funcMatch.Success) - { - var funcName = funcMatch.Groups[1].Value.ToUpperInvariant(); - if (funcName != "CONVERT_IMPLICIT" && IsFunctionOnColumnSide(predicate, funcMatch)) - return $"Function call ({funcName}) on column"; - } - - // Leading wildcard LIKE - if (LeadingWildcardLikeRegex.IsMatch(predicate)) - return "Leading wildcard LIKE pattern"; - - return null; - } - - /// - /// Checks whether a function call in a predicate is on the column side of the comparison. - /// Predicate ScalarStrings look like: [db].[schema].[table].[col]>dateadd(day,(0),[@var]) - /// If the function is only on the parameter/literal side, it's still SARGable. - /// - private static bool IsFunctionOnColumnSide(string predicate, Match funcMatch) - { - // Find the comparison operator that splits the predicate into left/right sides. - // Operators in ScalarString: >=, <=, <>, >, <, = - var compMatch = Regex.Match(predicate, @"(?])([<>=!]{1,2})(?![<>=])"); - if (!compMatch.Success) - return true; // No comparison found — can't determine side, assume worst case - - var compPos = compMatch.Index; - var funcPos = funcMatch.Index; - - // Determine which side the function is on - var funcSide = funcPos < compPos ? "left" : "right"; - - // Check if that side also contains a column reference [...].[...].[...] - string side = funcSide == "left" - ? predicate[..compPos] - : predicate[(compPos + compMatch.Length)..]; - - // Column references are multi-part bracket-qualified: [schema].[table].[column] - // Variables are [@var] or [@var] — single bracket pair with @ prefix. - // Match [identifier].[identifier] (at least two dotted parts) to distinguish columns. - return Regex.IsMatch(side, @"\[[^\]@]+\]\.\["); - } - - /// - /// Verifies the OR expansion chain walking up from a Concatenation node: - /// Nested Loops → Merge Interval → TopN Sort → [Compute Scalar] → Concatenation - /// - private static bool IsOrExpansionChain(PlanNode concatenationNode) - { - // Walk up, skipping Compute Scalar - var parent = concatenationNode.Parent; - while (parent != null && parent.PhysicalOp == "Compute Scalar") - parent = parent.Parent; - - // Expect TopN Sort (XML says "TopN Sort", parser normalizes to "Top N Sort") - if (parent == null || parent.LogicalOp != "Top N Sort") - return false; - - // Walk up to Merge Interval - parent = parent.Parent; - if (parent == null || parent.PhysicalOp != "Merge Interval") - return false; - - // Walk up to Nested Loops - parent = parent.Parent; - if (parent == null || parent.PhysicalOp != "Nested Loops") - return false; - - // If this Nested Loops is inside an Anti/Semi Join, this is a NOT IN/IN - // subquery pattern (Merge Interval optimizing range lookups), not an OR expansion - var nlParent = parent.Parent; - if (nlParent != null && nlParent.LogicalOp != null && - nlParent.LogicalOp.Contains("Semi")) - return false; - - return true; - } - - /// - /// Finds Sort and Hash Match operators in the tree that consume memory. - /// - /// - /// Returns true if the plan contains an adaptive join that executed as a Nested Loop. - /// Indicates a memory grant was sized for the hash alternative but never needed. - /// - private static bool HasAdaptiveJoinChoseNestedLoop(PlanNode node) - { - if (node.IsAdaptive && node.ActualJoinType != null - && node.ActualJoinType.Contains("Nested", StringComparison.OrdinalIgnoreCase)) - return true; - - foreach (var child in node.Children) - if (HasAdaptiveJoinChoseNestedLoop(child)) - return true; - - return false; - } - - private static void FindMemoryConsumers(PlanNode node, List consumers) - { - // Collect all consumers first, then sort by row count descending - var raw = new List<(string Label, double Rows)>(); - FindMemoryConsumersRecursive(node, raw); - - foreach (var (label, _) in raw.OrderByDescending(c => c.Rows)) - consumers.Add(label); - } - - private static void FindMemoryConsumersRecursive(PlanNode node, List<(string Label, double Rows)> consumers) - { - if (node.PhysicalOp.Contains("Sort", StringComparison.OrdinalIgnoreCase) && - !node.PhysicalOp.Contains("Spool", StringComparison.OrdinalIgnoreCase)) - { - var rowCount = node.HasActualStats ? node.ActualRows : node.EstimateRows; - var rows = node.HasActualStats - ? $"{node.ActualRows:N0} actual rows" - : $"{node.EstimateRows:N0} estimated rows"; - consumers.Add(($"Sort (Node {node.NodeId}, {rows})", rowCount)); - } - else if (node.PhysicalOp.Contains("Hash", StringComparison.OrdinalIgnoreCase)) - { - var rowCount = node.HasActualStats ? node.ActualRows : node.EstimateRows; - var rows = node.HasActualStats - ? $"{node.ActualRows:N0} actual rows" - : $"{node.EstimateRows:N0} estimated rows"; - consumers.Add(($"Hash Match (Node {node.NodeId}, {rows})", rowCount)); - } - - foreach (var child in node.Children) - FindMemoryConsumersRecursive(child, consumers); - } - - /// - /// Calculates an operator's own elapsed time by subtracting child time. - /// In batch mode, operator times are self-contained (exclusive). - /// In row mode, times are cumulative (include all children below). - /// For parallel plans, we calculate self-time per-thread then take the max, - /// avoiding cross-thread subtraction errors. - /// Exchange operators accumulate downstream wait time (e.g. from spilling - /// children) so their self-time is unreliable — see sql.kiwi/2021/03. - /// - internal static long GetOperatorOwnElapsedMs(PlanNode node) - { - if (node.ActualExecutionMode == "Batch") - return node.ActualElapsedMs; - - // Parallel plan with per-thread data: calculate self-time per thread - if (node.PerThreadStats.Count > 1) - return GetPerThreadOwnElapsed(node); - - // Serial row mode: subtract all direct children's elapsed time - return GetSerialOwnElapsed(node); - } - - /// - /// Per-thread self-time calculation for parallel row mode operators. - /// For each thread: self = parent_elapsed[t] - sum(children_elapsed[t]). - /// Returns max across threads. - /// - private static long GetPerThreadOwnElapsed(PlanNode node) - { - // Build lookup: threadId -> parent elapsed for this node - var parentByThread = new Dictionary(); - foreach (var ts in node.PerThreadStats) - parentByThread[ts.ThreadId] = ts.ActualElapsedMs; - - // Build lookup: threadId -> sum of all direct children's elapsed - var childSumByThread = new Dictionary(); - foreach (var child in node.Children) - { - var childNode = child; - - // Exchange operators have unreliable times — look through to their child - if (child.PhysicalOp == "Parallelism" && child.Children.Count > 0) - childNode = child.Children.OrderByDescending(c => c.ActualElapsedMs).First(); - - foreach (var ts in childNode.PerThreadStats) - { - childSumByThread.TryGetValue(ts.ThreadId, out var existing); - childSumByThread[ts.ThreadId] = existing + ts.ActualElapsedMs; - } - } - - // Self-time per thread = parent - children, take max across threads - var maxSelf = 0L; - foreach (var (threadId, parentMs) in parentByThread) - { - childSumByThread.TryGetValue(threadId, out var childMs); - var self = Math.Max(0, parentMs - childMs); - if (self > maxSelf) maxSelf = self; - } - - return maxSelf; - } - - /// - /// Max per-thread self-CPU for this operator. - /// Parallel: for each thread, self_cpu = thread_cpu - Σ same-thread child cpu; take max. - /// Serial / single-thread: operator_cpu - Σ effective child cpu. - /// Needed for external-wait benefit scoring (Joe's formula). - /// - internal static long GetOperatorMaxThreadOwnCpuMs(PlanNode node) - { - if (!node.HasActualStats || node.ActualCPUMs <= 0) return 0; - - if (node.PerThreadStats.Count > 1) - { - var parentByThread = new Dictionary(); - foreach (var ts in node.PerThreadStats) - parentByThread[ts.ThreadId] = ts.ActualCPUMs; - - var childSumByThread = new Dictionary(); - foreach (var child in node.Children) - { - var childNode = child; - if (child.PhysicalOp == "Parallelism" && child.Children.Count > 0) - childNode = child.Children.OrderByDescending(c => c.ActualCPUMs).First(); - foreach (var ts in childNode.PerThreadStats) - { - childSumByThread.TryGetValue(ts.ThreadId, out var existing); - childSumByThread[ts.ThreadId] = existing + ts.ActualCPUMs; - } - } - - var maxSelf = 0L; - foreach (var (threadId, parentCpu) in parentByThread) - { - childSumByThread.TryGetValue(threadId, out var childCpu); - var self = Math.Max(0, parentCpu - childCpu); - if (self > maxSelf) maxSelf = self; - } - return maxSelf; - } - - // Serial: operator_cpu - Σ effective child cpu - var totalChildCpu = 0L; - foreach (var child in node.Children) - totalChildCpu += GetEffectiveChildCpuMs(child); - return Math.Max(0, node.ActualCPUMs - totalChildCpu); - } - - private static long GetEffectiveChildCpuMs(PlanNode child) - { - if (child.PhysicalOp == "Parallelism" && child.Children.Count > 0) - return child.Children.Max(GetEffectiveChildCpuMs); - if (child.ActualCPUMs > 0) - return child.ActualCPUMs; - if (child.Children.Count == 0) - return 0; - var sum = 0L; - foreach (var grandchild in child.Children) - sum += GetEffectiveChildCpuMs(grandchild); - return sum; - } - - /// - /// Serial row mode self-time: subtract all direct children's effective elapsed. - /// Pass-through operators (Compute Scalar, etc.) don't carry runtime stats — - /// look through them to the first descendant that does. Exchange children - /// use max-child elapsed because exchange times are unreliable. - /// - private static long GetSerialOwnElapsed(PlanNode node) - { - var totalChildElapsed = 0L; - foreach (var child in node.Children) - totalChildElapsed += GetEffectiveChildElapsedMs(child); - - return Math.Max(0, node.ActualElapsedMs - totalChildElapsed); - } - - /// - /// Returns the elapsed time a child contributes to its parent's subtree. - /// Looks through pass-through operators (Compute Scalar, Parallelism exchange) - /// that don't carry reliable runtime stats. - /// - private static long GetEffectiveChildElapsedMs(PlanNode child) - { - // Exchange operators: unreliable times, use max child - if (child.PhysicalOp == "Parallelism" && child.Children.Count > 0) - return child.Children.Max(GetEffectiveChildElapsedMs); - - // Batch mode pipelines — each operator's elapsed stands alone rather than - // rolling up its descendants the way row-mode does. For a parent computing - // self-time above a batch-mode subtree, subtract the whole pipeline's time - // (Joe #215 D1: Parallelism gather-streams above three batch operators). - var mode = child.ActualExecutionMode ?? child.ExecutionMode; - if (mode == "Batch" && child.HasActualStats) - return SumBatchSubtreeElapsedMs(child); - - // Child has its own stats: use them - if (child.ActualElapsedMs > 0) - return child.ActualElapsedMs; - - // No stats (Compute Scalar and similar): look through to descendants - if (child.Children.Count == 0) - return 0; - - var sum = 0L; - foreach (var grandchild in child.Children) - sum += GetEffectiveChildElapsedMs(grandchild); - return sum; - } - - /// - /// Sums ActualElapsedMs across a contiguous batch-mode subtree (stops at - /// Parallelism exchange zone boundaries). Batch operators pipeline — elapsed - /// times are standalone, not cumulative — so summing gives the total work the - /// zone did, which is what a row-mode parent above the zone should subtract - /// to get its own self-time. - /// - private static long SumBatchSubtreeElapsedMs(PlanNode node) - { - long sum = node.ActualElapsedMs; - foreach (var child in node.Children) - { - // Zone boundary — stop summing - if (child.PhysicalOp == "Parallelism") continue; - - var childMode = child.ActualExecutionMode ?? child.ExecutionMode; - if (childMode == "Batch" && child.HasActualStats) - sum += SumBatchSubtreeElapsedMs(child); - else - sum += GetEffectiveChildElapsedMs(child); - } - return sum; - } - - /// - /// Calculates a Parallelism (exchange) operator's own elapsed time. - /// Exchange times are unreliable — they accumulate wait time caused by - /// downstream operators (e.g. spilling sorts). This returns a best-effort - /// value but callers should treat it with caution. - /// - private static long GetParallelismOperatorElapsedMs(PlanNode node) - { - if (node.Children.Count == 0) - return node.ActualElapsedMs; - - if (node.PerThreadStats.Count > 1) - return GetPerThreadOwnElapsed(node); - - var maxChildElapsed = node.Children.Max(c => c.ActualElapsedMs); - return Math.Max(0, node.ActualElapsedMs - maxChildElapsed); - } - - /// - /// Quantifies the cost of work below a Filter operator by summing child subtree metrics. - /// Shows how many rows, reads, and elapsed time were spent producing rows that the - /// Filter then discarded. - /// - private static string QuantifyFilterImpact(PlanNode filterNode) - { - if (filterNode.Children.Count == 0) - return ""; - - var parts = new List(); - - // Rows input vs output — how many rows did the filter discard? - var inputRows = filterNode.Children.Sum(c => c.ActualRows); - if (filterNode.HasActualStats && inputRows > 0 && filterNode.ActualRows < inputRows) - { - var discarded = inputRows - filterNode.ActualRows; - var pct = (double)discarded / inputRows * 100; - parts.Add($"{discarded:N0} of {inputRows:N0} rows discarded ({pct:N0}%)"); - } - - // Logical reads across the entire child subtree - long totalReads = 0; - foreach (var child in filterNode.Children) - totalReads += SumSubtreeReads(child); - if (totalReads > 0) - parts.Add($"{totalReads:N0} logical reads below"); - - // Elapsed time: use the direct child's time (cumulative in row mode, includes its children) - var childElapsed = filterNode.Children.Max(c => c.ActualElapsedMs); - if (childElapsed > 0) - parts.Add($"{childElapsed:N0}ms elapsed below"); - - if (parts.Count == 0) - return ""; - - return string.Join("\n", parts.Select(p => "• " + p)); - } - - /// - /// Detects well-known CE default selectivity guesses by comparing EstimateRows to TableCardinality. - /// Returns a description of the guess pattern, or null if no known pattern matches. - /// - private static string? DetectCeGuess(double estimateRows, double tableCardinality) - { - if (tableCardinality <= 0) return null; - var selectivity = estimateRows / tableCardinality; - - // Known CE guess selectivities with a 2% tolerance band - return selectivity switch - { - >= 0.29 and <= 0.31 => $"matches the 30% equality guess ({selectivity * 100:N1}%)", - >= 0.098 and <= 0.102 => $"matches the 10% inequality guess ({selectivity * 100:N1}%)", - >= 0.088 and <= 0.092 => $"matches the 9% LIKE/BETWEEN guess ({selectivity * 100:N1}%)", - >= 0.155 and <= 0.175 => $"matches the ~16.4% compound predicate guess ({selectivity * 100:N1}%)", - >= 0.009 and <= 0.011 => $"matches the 1% multi-inequality guess ({selectivity * 100:N1}%)", - _ => null - }; - } - - private static long SumSubtreeReads(PlanNode node) - { - long reads = node.ActualLogicalReads; - foreach (var child in node.Children) - reads += SumSubtreeReads(child); - return reads; - } - - /// - private record ScanImpact(double CostPct, double ElapsedPct, string? Summary); - - /// - /// Builds impact details for a scan node: what % of plan time/cost it represents, - /// and what fraction of rows survived filtering. - /// - private static ScanImpact BuildScanImpactDetails(PlanNode node, PlanStatement stmt) - { - var parts = new List(); - - // % of plan cost - double costPct = 0; - if (stmt.StatementSubTreeCost > 0 && node.EstimatedTotalSubtreeCost > 0) - { - costPct = node.EstimatedTotalSubtreeCost / stmt.StatementSubTreeCost * 100; - if (costPct >= 50) - parts.Add($"This scan is {costPct:N0}% of the plan cost."); - } - - // % of elapsed time (actual plans) - double elapsedPct = 0; - if (node.HasActualStats && node.ActualElapsedMs > 0 && - stmt.QueryTimeStats != null && stmt.QueryTimeStats.ElapsedTimeMs > 0) - { - elapsedPct = (double)node.ActualElapsedMs / stmt.QueryTimeStats.ElapsedTimeMs * 100; - if (elapsedPct >= 50) - parts.Add($"This scan took {elapsedPct:N0}% of elapsed time."); - } - - // Row selectivity: rows returned vs rows read (actual) or vs table cardinality (estimated) - if (node.HasActualStats && node.ActualRowsRead > 0 && node.ActualRows < node.ActualRowsRead) - { - var selectivity = (double)node.ActualRows / node.ActualRowsRead * 100; - if (selectivity < 10) - parts.Add($"Only {selectivity:N3}% of rows survived filtering ({node.ActualRows:N0} of {node.ActualRowsRead:N0})."); - } - else if (!node.HasActualStats && node.TableCardinality > 0 && node.EstimateRows < node.TableCardinality) - { - var selectivity = node.EstimateRows / node.TableCardinality * 100; - if (selectivity < 10) - parts.Add($"Only {selectivity:N1}% of rows estimated to survive filtering."); - } - - return new ScanImpact(costPct, elapsedPct, parts.Count > 0 ? string.Join(" ", parts) : null); - } - - /// Determines whether a row estimate mismatch actually caused observable harm. - /// Returns a description of the harm, or null if the bad estimate is benign. - /// - /// False-positive suppression (from reviewer feedback): - /// - Root node (no parent) — nothing above to be harmed by the bad estimate - /// - Sort that didn't spill — the estimate was wrong but no harm done - /// - /// Real harm: - /// - The node itself has a spill warning (bad estimate → bad memory grant) - /// - The node is a join (wrong join type or excessive inner side work) - /// - A parent join may have chosen the wrong strategy based on bad row count - /// - A parent Sort/Hash spilled (downstream estimate caused bad grant) - /// - /// - /// Returns a short label describing what a wait type means (e.g., "I/O — reading from disk"). - /// Public for use by UI components that annotate wait stats inline. - /// - public static string GetWaitLabel(string waitType) - { - var wt = waitType.ToUpperInvariant(); - return wt switch - { - _ when wt.StartsWith("PAGEIOLATCH") => "I/O — reading data from disk", - _ when wt.Contains("IO_COMPLETION") => "I/O — spills to TempDB or eager writes", - _ when wt == "SOS_SCHEDULER_YIELD" => "CPU — scheduler yielding", - _ when wt.StartsWith("CXPACKET") || wt.StartsWith("CXCONSUMER") => "parallelism — thread skew", - _ when wt.StartsWith("CXSYNC") => "parallelism — exchange synchronization", - _ when wt == "HTBUILD" => "hash — building hash table", - _ when wt == "HTDELETE" => "hash — cleaning up hash table", - _ when wt == "HTREPARTITION" => "hash — repartitioning", - _ when wt.StartsWith("HT") => "hash operation", - _ when wt == "BPSORT" => "batch sort", - _ when wt == "BMPBUILD" => "bitmap filter build", - _ when wt.Contains("MEMORY_ALLOCATION_EXT") => "memory allocation", - _ when wt.StartsWith("PAGELATCH") => "page latch — in-memory contention", - _ when wt.StartsWith("LATCH_") => "latch contention", - _ when wt.StartsWith("LCK_") => "lock contention", - _ when wt == "LOGBUFFER" => "transaction log writes", - _ when wt == "ASYNC_NETWORK_IO" => "network — client not consuming results", - _ when wt == "SOS_PHYS_PAGE_CACHE" => "physical page cache contention", - _ => "" - }; - } - - /// - /// Returns true if the statement has significant I/O waits (PAGEIOLATCH_*, IO_COMPLETION). - /// Used for severity elevation decisions where I/O specifically indicates disk access. - /// Thresholds: I/O waits >= 20% of total wait time AND >= 100ms absolute. - /// - private static bool HasSignificantIoWaits(List waits) - { - if (waits.Count == 0) - return false; - - var totalMs = waits.Sum(w => w.WaitTimeMs); - if (totalMs == 0) - return false; - - long ioMs = 0; - foreach (var w in waits) - { - var wt = w.WaitType.ToUpperInvariant(); - if (wt.StartsWith("PAGEIOLATCH") || wt.Contains("IO_COMPLETION")) - ioMs += w.WaitTimeMs; - } - - var pct = (double)ioMs / totalMs * 100; - return ioMs >= 100 && pct >= 20; - } - - private static bool AllocatesResources(PlanNode node) - { - // Operators that get memory grants or allocate structures based on row estimates. - // Hash Match (hash table), Sort (sort buffer), Spool (worktable). - var op = node.PhysicalOp; - return op.StartsWith("Hash", StringComparison.OrdinalIgnoreCase) - || op.StartsWith("Sort", StringComparison.OrdinalIgnoreCase) - || op.EndsWith("Spool", StringComparison.OrdinalIgnoreCase); - } - - private static string? AssessEstimateHarm(PlanNode node, double ratio) - { - // Root node: no parent to harm. - // The synthetic statement root (SELECT/INSERT/etc.) has NodeId == -1. - if (node.Parent == null || node.Parent.NodeId == -1) - return null; - - // The node itself has a spill — bad estimate caused bad memory grant - if (HasSpillWarning(node)) - { - return ratio >= 10.0 - ? "The underestimate likely caused an insufficient memory grant, leading to a spill to TempDB." - : "The overestimate may have caused an excessive memory grant, wasting workspace memory."; - } - - // Sort/Hash that did NOT spill — estimate was wrong but no observable harm - if ((node.PhysicalOp.Contains("Sort", StringComparison.OrdinalIgnoreCase) || - node.PhysicalOp.Contains("Hash", StringComparison.OrdinalIgnoreCase)) && - !HasSpillWarning(node)) - { - return null; - } - - // The node is a join — bad estimate means wrong join type or excessive work - // Adaptive joins (2017+) switch strategy at runtime, so the estimate didn't lock in a bad choice. - if (node.LogicalOp.Contains("Join", StringComparison.OrdinalIgnoreCase) && !node.IsAdaptive) - { - return ratio >= 10.0 - ? "The underestimate may have caused the optimizer to make poor choices." - : "The overestimate may have caused the optimizer to make poor choices."; - } - - // Walk up to check if a parent was harmed by this bad estimate - var ancestor = node.Parent; - while (ancestor != null) - { - // Transparent operators — skip through - if (ancestor.PhysicalOp == "Parallelism" || - ancestor.PhysicalOp == "Compute Scalar" || - ancestor.PhysicalOp == "Segment" || - ancestor.PhysicalOp == "Sequence Project" || - ancestor.PhysicalOp == "Top" || - ancestor.PhysicalOp == "Filter") - { - ancestor = ancestor.Parent; - continue; - } - - // Parent join — bad row count from below caused wrong join choice - // Adaptive joins handle this at runtime, so skip them. - if (ancestor.LogicalOp.Contains("Join", StringComparison.OrdinalIgnoreCase)) - { - if (ancestor.IsAdaptive) - return null; // Adaptive join self-corrects — no harm - - return ratio >= 10.0 - ? $"The underestimate may have caused the optimizer to make poor choices." - : $"The overestimate may have caused the optimizer to make poor choices."; - } - - // Parent Sort/Hash that spilled — downstream bad estimate caused the spill - if (HasSpillWarning(ancestor)) - { - return ratio >= 10.0 - ? $"The underestimate contributed to {ancestor.PhysicalOp} (Node {ancestor.NodeId}) spilling to TempDB." - : $"The overestimate contributed to {ancestor.PhysicalOp} (Node {ancestor.NodeId}) receiving an excessive memory grant."; - } - - // Parent Sort/Hash with no spill — benign - if (ancestor.PhysicalOp.Contains("Sort", StringComparison.OrdinalIgnoreCase) || - ancestor.PhysicalOp.Contains("Hash", StringComparison.OrdinalIgnoreCase)) - { - return null; - } - - // Any other operator — stop walking - break; - } - - // Default: the estimate is off but we can't identify specific harm - return null; - } - - /// - /// Checks if a node has any spill-related warnings (Sort/Hash/Exchange spills). - /// - private static bool HasSpillWarning(PlanNode node) - { - return node.Warnings.Any(w => w.SpillDetails != null); - } - - /// - /// Formats a node reference for use in warning messages. Includes object name - /// for data access operators where it helps identify which table is involved. - /// - private static string FormatNodeRef(PlanNode node) - { - if (!string.IsNullOrEmpty(node.ObjectName)) - { - var objRef = !string.IsNullOrEmpty(node.DatabaseName) - ? $"{node.DatabaseName}.{node.ObjectName}" - : node.ObjectName; - return $"{node.PhysicalOp} on {objRef} (Node {node.NodeId})"; - } - - return $"{node.PhysicalOp} (Node {node.NodeId})"; - } - - /// - /// Identifies the specific cause of a row goal from the statement text. - /// Returns a specific cause when detectable, or a generic list as fallback. - /// - private static string IdentifyRowGoalCause(string stmtText) - { - if (string.IsNullOrEmpty(stmtText)) - return "TOP, EXISTS, IN, or FAST hint"; - - var text = stmtText.ToUpperInvariant(); - var causes = new List(4); - - if (Regex.IsMatch(text, @"\bTOP\b")) - causes.Add("TOP"); - if (Regex.IsMatch(text, @"\bEXISTS\b")) - causes.Add("EXISTS"); - // IN with subquery — bare "IN (" followed by SELECT, not just "IN (1,2,3)" - if (Regex.IsMatch(text, @"\bIN\s*\(\s*SELECT\b")) - causes.Add("IN (subquery)"); - if (Regex.IsMatch(text, @"\bFAST\b")) - causes.Add("FAST hint"); - - return causes.Count > 0 - ? string.Join(", ", causes) - : "TOP, EXISTS, IN, or FAST hint"; - } - - private static string Truncate(string value, int maxLength) - { - return value.Length <= maxLength ? value : value[..maxLength] + "..."; - } } diff --git a/src/PlanViewer.Web/PlanViewer.Web.csproj b/src/PlanViewer.Web/PlanViewer.Web.csproj index 606265c..f570b55 100644 --- a/src/PlanViewer.Web/PlanViewer.Web.csproj +++ b/src/PlanViewer.Web/PlanViewer.Web.csproj @@ -20,6 +20,11 @@ + + + + + From a1665ad7e0977238b5cd5d6c0e2d2e79d50803cc Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Tue, 12 May 2026 22:30:41 -0500 Subject: [PATCH 16/27] Split QueryStoreGridControl.axaml.cs into partial classes (#330) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move-only refactor; no behavior changes. QueryStoreGridControl.axaml.cs (1,959 lines) split into 7 partials: Fetch (452) - Fetch_Click + FetchPlans/WaitStats + grouping Filters (256) - search type/value + column filter popup + ApplyFilters GroupBy (161) - GroupBy + ReorderColumns + row expand/collapse + ResultsGrid_DoubleTapped + ToggleRowExpansion Sort (245) - sorting + UpdateBarRatios + GetSortKey + status Selection (134) - select/load/ViewHistory/context menu + copy WaitStats (182) - wait fetch + category click + mode toggle TimeRange ( 33) - OnTimeRangeChanged Main file now 465 lines — fields, ctor, public API (SetInitialTimeRange), DB picker, and static accessor dictionaries. The standalone QueryStoreRow class at the bottom of the file is untouched. Build clean: 0 errors, 0 warnings on PlanViewer.App. Co-authored-by: Claude Opus 4.7 (1M context) --- .../Controls/QueryStoreGridControl.Fetch.cs | 488 ++++++ .../Controls/QueryStoreGridControl.Filters.cs | 297 ++++ .../Controls/QueryStoreGridControl.GroupBy.cs | 195 +++ .../QueryStoreGridControl.Selection.cs | 168 ++ .../Controls/QueryStoreGridControl.Sort.cs | 282 ++++ .../QueryStoreGridControl.TimeRange.cs | 34 + .../QueryStoreGridControl.WaitStats.cs | 219 +++ .../Controls/QueryStoreGridControl.axaml.cs | 1494 ----------------- 8 files changed, 1683 insertions(+), 1494 deletions(-) create mode 100644 src/PlanViewer.App/Controls/QueryStoreGridControl.Fetch.cs create mode 100644 src/PlanViewer.App/Controls/QueryStoreGridControl.Filters.cs create mode 100644 src/PlanViewer.App/Controls/QueryStoreGridControl.GroupBy.cs create mode 100644 src/PlanViewer.App/Controls/QueryStoreGridControl.Selection.cs create mode 100644 src/PlanViewer.App/Controls/QueryStoreGridControl.Sort.cs create mode 100644 src/PlanViewer.App/Controls/QueryStoreGridControl.TimeRange.cs create mode 100644 src/PlanViewer.App/Controls/QueryStoreGridControl.WaitStats.cs diff --git a/src/PlanViewer.App/Controls/QueryStoreGridControl.Fetch.cs b/src/PlanViewer.App/Controls/QueryStoreGridControl.Fetch.cs new file mode 100644 index 0000000..2179cb3 --- /dev/null +++ b/src/PlanViewer.App/Controls/QueryStoreGridControl.Fetch.cs @@ -0,0 +1,488 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.VisualTree; +using PlanViewer.App.Dialogs; +using PlanViewer.App.Services; +using PlanViewer.Core.Interfaces; +using PlanViewer.Core.Models; +using PlanViewer.Core.Services; + +namespace PlanViewer.App.Controls; + +public partial class QueryStoreGridControl : UserControl +{ + private async void Fetch_Click(object? sender, RoutedEventArgs e) + { + _fetchCts?.Cancel(); + _fetchCts?.Dispose(); + _fetchCts = new CancellationTokenSource(); + var ct = _fetchCts.Token; + + var orderBy = (OrderByBox.SelectedItem as ComboBoxItem)?.Tag?.ToString() ?? "cpu"; + _lastFetchedOrderBy = orderBy; + + FetchButton.IsEnabled = false; + LoadButton.IsEnabled = false; + StatusText.Text = "Loading time slicer..."; + _rows.Clear(); + _filteredRows.Clear(); + + try + { + // Load slicer data, preserving the current selection if one exists. + // Without this, LoadData defaults to last 24h and the user's range is lost. + await LoadTimeSlicerDataAsync(orderBy, ct, _slicerStartUtc, _slicerEndUtc); + } + catch (OperationCanceledException) + { + StatusText.Text = "Cancelled."; + } + catch (Exception ex) + { + StatusText.Text = ex.Message.Length > 80 ? ex.Message[..80] + "..." : ex.Message; + } + finally + { + FetchButton.IsEnabled = true; + } + } + + private async System.Threading.Tasks.Task FetchPlansForRangeAsync() + { + _fetchCts?.Cancel(); + _fetchCts?.Dispose(); + _fetchCts = new CancellationTokenSource(); + var ct = _fetchCts.Token; + + var topN = (int)(TopNBox.Value ?? 25); + var orderBy = _lastFetchedOrderBy; + var filter = BuildSearchFilter(); + + FetchButton.IsEnabled = false; + LoadButton.IsEnabled = false; + StatusText.Text = "Fetching plans..."; + GridLoadingOverlay.IsVisible = true; + GridLoadingText.Text = "Fetching plans..."; + GridEmptyMessage.IsVisible = false; + _rows.Clear(); + _filteredRows.Clear(); + _groupedRootRows.Clear(); + + // Start global + ribbon wait stats early (they don't depend on plan results) + if (_waitStatsSupported && _waitStatsEnabled && _slicerStartUtc.HasValue && _slicerEndUtc.HasValue) + _ = FetchGlobalWaitStatsOnlyAsync(_slicerStartUtc.Value, _slicerEndUtc.Value, ct); + + try + { + if (_groupByMode == QueryStoreGroupBy.None) + { + await FetchFlatPlansAsync(topN, orderBy, filter, ct); + } + else + { + await FetchGroupedPlansAsync(topN, orderBy, filter, ct); + } + } + catch (OperationCanceledException) + { + StatusText.Text = "Cancelled."; + } + catch (Exception ex) + { + StatusText.Text = ex.Message.Length > 80 ? ex.Message[..80] + "..." : ex.Message; + } + finally + { + GridLoadingOverlay.IsVisible = false; + FetchButton.IsEnabled = true; + } + } + + private async System.Threading.Tasks.Task FetchFlatPlansAsync( + int topN, string orderBy, QueryStoreFilter? filter, CancellationToken ct) + { + var plans = await QueryStoreService.FetchTopPlansAsync( + _connectionString, topN, orderBy, filter: filter, ct: ct, + startUtc: _slicerStartUtc, endUtc: _slicerEndUtc); + + GridLoadingOverlay.IsVisible = false; + + if (plans.Count == 0) + { + StatusText.Text = "No Query Store data found for the selected range."; + return; + } + + foreach (var plan in plans) + _rows.Add(new QueryStoreRow(plan)); + + ApplyFilters(); + LoadButton.IsEnabled = true; + SelectToggleButton.Content = "Select All"; + + // Fetch per-plan wait stats after grid is populated (needs plan IDs) + if (_waitStatsSupported && _waitStatsEnabled && _slicerStartUtc.HasValue && _slicerEndUtc.HasValue) + _ = FetchPerPlanWaitStatsAsync(_slicerStartUtc.Value, _slicerEndUtc.Value, ct); + } + + private async System.Threading.Tasks.Task FetchGroupedPlansAsync( + int topN, string orderBy, QueryStoreFilter? filter, CancellationToken ct) + { + QueryStoreGroupedResult grouped; + if (_groupByMode == QueryStoreGroupBy.QueryHash) + { + grouped = await QueryStoreService.FetchGroupedByQueryHashAsync( + _connectionString, topN, orderBy, filter, ct, + _slicerStartUtc, _slicerEndUtc); + } + else // Module + { + grouped = await QueryStoreService.FetchGroupedByModuleAsync( + _connectionString, topN, orderBy, filter, ct, + _slicerStartUtc, _slicerEndUtc); + } + + GridLoadingOverlay.IsVisible = false; + GridEmptyMessage.IsVisible = false; + + if (grouped.IntermediateRows.Count == 0) + { + if (_groupByMode == QueryStoreGroupBy.Module) + { + GridEmptyMessageText.Text = "No module found in the selected period"; + GridEmptyMessage.IsVisible = true; + } + else + { + StatusText.Text = "No Query Store data found for the selected range."; + } + return; + } + + var rootRows = BuildGroupedRows(grouped); + + // Sort root rows by consolidated metric descending + var metricAccessor = GetMetricAccessor(orderBy); + rootRows = rootRows.OrderByDescending(r => metricAccessor(r)).ToList(); + _groupedRootRows = rootRows; + + // Flatten to _rows (all levels) and show only top-level in _filteredRows + foreach (var root in rootRows) + { + _rows.Add(root); + foreach (var mid in root.Children) + { + _rows.Add(mid); + foreach (var leaf in mid.Children) + _rows.Add(leaf); + } + } + + // Show only root-level rows initially (collapsed) + _filteredRows.Clear(); + foreach (var root in rootRows) + _filteredRows.Add(root); + + LoadButton.IsEnabled = true; + SelectToggleButton.Content = "Select All"; + + UpdateStatusText(); + UpdateBarRatios(); + + // Fetch per-plan wait stats for leaf rows, then consolidate upward + if (_waitStatsSupported && _waitStatsEnabled && _slicerStartUtc.HasValue && _slicerEndUtc.HasValue) + _ = FetchGroupedWaitStatsAsync(_slicerStartUtc.Value, _slicerEndUtc.Value, ct); + } + + /// + /// Fetches per-plan wait stats for all real plan IDs found in the grouped hierarchy, + /// assigns them to leaf rows, then consolidates upward to intermediate and root rows. + /// + private async System.Threading.Tasks.Task FetchGroupedWaitStatsAsync( + DateTime startUtc, DateTime endUtc, CancellationToken ct) + { + try + { + // Collect all real plan IDs from rows that have a real PlanId + var allPlanIds = _rows + .Where(r => r.PlanId > 0) + .Select(r => r.PlanId) + .Distinct() + .ToList(); + + if (allPlanIds.Count == 0) return; + + var planWaits = await QueryStoreService.FetchPlanWaitStatsAsync( + _connectionString, startUtc, endUtc, allPlanIds, ct); + if (ct.IsCancellationRequested) return; + + // Build lookup: plan_id → list of WaitCategoryTotal + var byPlan = planWaits + .GroupBy(x => x.PlanId) + .ToDictionary(g => g.Key, g => g.Select(x => x.Wait).ToList()); + + // 1. Assign raw waits + profiles to rows with a real PlanId + foreach (var row in _rows) + { + if (row.PlanId > 0 && byPlan.TryGetValue(row.PlanId, out var waits)) + { + row.RawWaitCategories = waits; + row.WaitProfile = QueryStoreService.BuildWaitProfile(waits); + } + } + + // 2. Consolidate upward through the hierarchy + foreach (var root in _groupedRootRows) + ConsolidateWaitProfileUpward(root); + + UpdateWaitBarMode(); + } + catch (OperationCanceledException) { } + catch (Exception) { } + } + + /// + /// Recursively consolidates wait profiles from children into their parent. + /// For each parent: merges all children's RawWaitCategories by summing WaitRatio + /// per category, then builds a new WaitProfile from the merged totals. + /// + private static void ConsolidateWaitProfileUpward(QueryStoreRow parent) + { + if (parent.Children.Count == 0) return; + + // Recurse first so children are consolidated before we merge them + foreach (var child in parent.Children) + ConsolidateWaitProfileUpward(child); + + // Merge all children's raw wait categories by summing WaitRatio per category + var merged = parent.Children + .SelectMany(c => c.RawWaitCategories) + .GroupBy(w => new { w.WaitCategory, w.WaitCategoryDesc }) + .Select(g => new WaitCategoryTotal + { + WaitCategory = g.Key.WaitCategory, + WaitCategoryDesc = g.Key.WaitCategoryDesc, + WaitRatio = g.Sum(w => w.WaitRatio), + }) + .ToList(); + + if (merged.Count > 0) + { + parent.RawWaitCategories = merged; + parent.WaitProfile = QueryStoreService.BuildWaitProfile(merged); + } + } + + /// Maps an orderBy metric string to a Func that extracts the sort value from a QueryStoreRow. + private static Func GetMetricAccessor(string orderBy) => orderBy.ToLowerInvariant() switch + { + "cpu" => r => r.TotalCpuSort, + "avg-cpu" => r => r.AvgCpuSort, + "duration" => r => r.TotalDurSort, + "avg-duration" => r => r.AvgDurSort, + "reads" => r => r.TotalReadsSort, + "avg-reads" => r => r.AvgReadsSort, + "writes" => r => r.TotalWritesSort, + "avg-writes" => r => r.AvgWritesSort, + "physical-reads" => r => r.TotalPhysReadsSort, + "avg-physical-reads" => r => r.AvgPhysReadsSort, + "memory" => r => r.TotalMemSort, + "avg-memory" => r => r.AvgMemSort, + "executions" => r => r.ExecsSort, + _ => r => r.TotalCpuSort, + }; + + private List BuildGroupedRows(QueryStoreGroupedResult grouped) + { + var roots = new List(); + var metricAccessor = GetMetricAccessor(_lastFetchedOrderBy); + + if (_groupByMode == QueryStoreGroupBy.QueryHash) + { + // Level 0: QueryHash groups + var queryHashGroups = grouped.IntermediateRows + .GroupBy(r => r.QueryHash) + .ToList(); + + foreach (var qhGroup in queryHashGroups) + { + var qhKey = qhGroup.Key; + var intermediateRows = qhGroup.ToList(); + + // Build level-1 children (PlanHash) + var midChildren = new List(); + foreach (var mid in intermediateRows) + { + // Build level-2 children (QueryId/PlanId) + var leafChildren = new List(); + var leaves = grouped.LeafRows + .Where(l => l.QueryHash == mid.QueryHash && l.QueryPlanHash == mid.QueryPlanHash) + .ToList(); + foreach (var leaf in leaves) + { + var leafPlan = GroupedRowToPlan(leaf); + leafChildren.Add(new QueryStoreRow(leafPlan, 2, + $"Q:{leaf.QueryId} P:{leaf.PlanId}{(leaf.IsTopRepresentative ? " ★" : "")}", new List())); + } + + // Sort leaf children by metric descending + leafChildren = leafChildren.OrderByDescending(r => metricAccessor(r)).ToList(); + + var midPlan = GroupedRowToPlan(mid); + // Populate QueryText from the top representative leaf for this plan hash + var topLeafForMid = leaves.FirstOrDefault(l => l.IsTopRepresentative) ?? leaves.FirstOrDefault(); + if (topLeafForMid != null && !string.IsNullOrEmpty(topLeafForMid.QueryText)) + midPlan.QueryText = topLeafForMid.QueryText; + midChildren.Add(new QueryStoreRow(midPlan, 1, mid.QueryPlanHash, leafChildren)); + } + + // Sort mid children by metric descending + midChildren = midChildren.OrderByDescending(r => metricAccessor(r)).ToList(); + + // Aggregate metrics at QueryHash level + var aggPlan = AggregateGroupedRows(intermediateRows, qhKey, intermediateRows.FirstOrDefault()?.ModuleName ?? ""); + // Populate QueryText from the top representative leaf across all leaves in this query hash group + var topLeafForRoot = grouped.LeafRows + .Where(l => l.QueryHash == qhKey && l.IsTopRepresentative && !string.IsNullOrEmpty(l.QueryText)) + .FirstOrDefault() + ?? grouped.LeafRows.FirstOrDefault(l => l.QueryHash == qhKey && !string.IsNullOrEmpty(l.QueryText)); + if (topLeafForRoot != null) + aggPlan.QueryText = topLeafForRoot.QueryText; + roots.Add(new QueryStoreRow(aggPlan, 0, qhKey, midChildren)); + } + } + else // Module + { + // Level 0: Module groups + var moduleGroups = grouped.IntermediateRows + .GroupBy(r => r.ModuleName) + .ToList(); + + foreach (var modGroup in moduleGroups) + { + var modKey = modGroup.Key; + var intermediateRows = modGroup.ToList(); + + // Build level-1 children (QueryHash) + var midChildren = new List(); + foreach (var mid in intermediateRows) + { + // Build level-2 children (QueryId/PlanId) + var leafChildren = new List(); + var leaves = grouped.LeafRows + .Where(l => l.ModuleName == mid.ModuleName && l.QueryHash == mid.QueryHash) + .ToList(); + foreach (var leaf in leaves) + { + var leafPlan = GroupedRowToPlan(leaf); + leafChildren.Add(new QueryStoreRow(leafPlan, 2, + $"Q:{leaf.QueryId} P:{leaf.PlanId}{(leaf.IsTopRepresentative ? " ★" : "")}", new List())); + } + + // Sort leaf children by metric descending + leafChildren = leafChildren.OrderByDescending(r => metricAccessor(r)).ToList(); + + var midPlan = GroupedRowToPlan(mid); + // Populate QueryText from the top representative leaf for this query hash + var topLeafForMid = leaves.FirstOrDefault(l => l.IsTopRepresentative) ?? leaves.FirstOrDefault(); + if (topLeafForMid != null && !string.IsNullOrEmpty(topLeafForMid.QueryText)) + midPlan.QueryText = topLeafForMid.QueryText; + midChildren.Add(new QueryStoreRow(midPlan, 1, mid.QueryHash, leafChildren)); + } + + // Sort mid children by metric descending + midChildren = midChildren.OrderByDescending(r => metricAccessor(r)).ToList(); + + // Aggregate metrics at Module level + var aggPlan = AggregateGroupedRows(intermediateRows, "", modKey); + // Populate QueryText from the top representative leaf across all leaves in this module group + var topLeafForRoot = grouped.LeafRows + .Where(l => l.ModuleName == modKey && l.IsTopRepresentative && !string.IsNullOrEmpty(l.QueryText)) + .FirstOrDefault() + ?? grouped.LeafRows.FirstOrDefault(l => l.ModuleName == modKey && !string.IsNullOrEmpty(l.QueryText)); + if (topLeafForRoot != null) + aggPlan.QueryText = topLeafForRoot.QueryText; + roots.Add(new QueryStoreRow(aggPlan, 0, modKey, midChildren)); + } + } + + return roots; + } + + private static QueryStorePlan GroupedRowToPlan(QueryStoreGroupedPlanRow row) + { + var totalExecs = row.CountExecutions > 0 ? row.CountExecutions : 1; + return new QueryStorePlan + { + QueryId = row.QueryId, + PlanId = row.PlanId, + QueryHash = row.QueryHash, + QueryPlanHash = row.QueryPlanHash, + ModuleName = row.ModuleName, + QueryText = row.QueryText, + PlanXml = row.PlanXml, + CountExecutions = row.CountExecutions, + ExecutionTypeDesc = row.ExecutionTypeDesc, + TotalCpuTimeUs = row.TotalCpuTimeUs, + TotalDurationUs = row.TotalDurationUs, + TotalLogicalIoReads = row.TotalLogicalIoReads, + TotalLogicalIoWrites = row.TotalLogicalIoWrites, + TotalPhysicalIoReads = row.TotalPhysicalIoReads, + TotalMemoryGrantPages = row.TotalMemoryGrantPages, + AvgCpuTimeUs = (double)row.TotalCpuTimeUs / totalExecs, + AvgDurationUs = (double)row.TotalDurationUs / totalExecs, + AvgLogicalIoReads = (double)row.TotalLogicalIoReads / totalExecs, + AvgLogicalIoWrites = (double)row.TotalLogicalIoWrites / totalExecs, + AvgPhysicalIoReads = (double)row.TotalPhysicalIoReads / totalExecs, + AvgMemoryGrantPages = (double)row.TotalMemoryGrantPages / totalExecs, + LastExecutedUtc = row.LastExecutedUtc, + }; + } + + private static QueryStorePlan AggregateGroupedRows(List rows, string queryHash, string moduleName) + { + var totalExecs = rows.Sum(r => r.CountExecutions); + var safeExecs = totalExecs > 0 ? totalExecs : 1; + var totalCpu = rows.Sum(r => r.TotalCpuTimeUs); + var totalDur = rows.Sum(r => r.TotalDurationUs); + var totalReads = rows.Sum(r => r.TotalLogicalIoReads); + var totalWrites = rows.Sum(r => r.TotalLogicalIoWrites); + var totalPhysReads = rows.Sum(r => r.TotalPhysicalIoReads); + var totalMem = rows.Sum(r => r.TotalMemoryGrantPages); + var lastExec = rows.Max(r => r.LastExecutedUtc); + + return new QueryStorePlan + { + QueryHash = queryHash, + ModuleName = moduleName, + CountExecutions = totalExecs, + TotalCpuTimeUs = totalCpu, + TotalDurationUs = totalDur, + TotalLogicalIoReads = totalReads, + TotalLogicalIoWrites = totalWrites, + TotalPhysicalIoReads = totalPhysReads, + TotalMemoryGrantPages = totalMem, + AvgCpuTimeUs = (double)totalCpu / safeExecs, + AvgDurationUs = (double)totalDur / safeExecs, + AvgLogicalIoReads = (double)totalReads / safeExecs, + AvgLogicalIoWrites = (double)totalWrites / safeExecs, + AvgPhysicalIoReads = (double)totalPhysReads / safeExecs, + AvgMemoryGrantPages = (double)totalMem / safeExecs, + LastExecutedUtc = lastExec, + ExecutionTypeDesc = rows.FirstOrDefault()?.ExecutionTypeDesc ?? "", + }; + } +} diff --git a/src/PlanViewer.App/Controls/QueryStoreGridControl.Filters.cs b/src/PlanViewer.App/Controls/QueryStoreGridControl.Filters.cs new file mode 100644 index 0000000..0275553 --- /dev/null +++ b/src/PlanViewer.App/Controls/QueryStoreGridControl.Filters.cs @@ -0,0 +1,297 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.VisualTree; +using PlanViewer.App.Dialogs; +using PlanViewer.App.Services; +using PlanViewer.Core.Interfaces; +using PlanViewer.Core.Models; +using PlanViewer.Core.Services; + +namespace PlanViewer.App.Controls; + +public partial class QueryStoreGridControl : UserControl +{ + private void SearchType_SelectionChanged(object? sender, SelectionChangedEventArgs e) + { + if (SearchValuePanel is null || ExecutionTypePanel is null) + return; + + var tag = (SearchTypeBox.SelectedItem as ComboBoxItem)?.Tag?.ToString(); + var isExecutionType = tag == "execution-type"; + SearchValuePanel.IsVisible = !isExecutionType; + ExecutionTypePanel.IsVisible = isExecutionType; + } + + private QueryStoreFilter? BuildSearchFilter() + { + var searchType = (SearchTypeBox.SelectedItem as ComboBoxItem)?.Tag?.ToString(); + + if (string.IsNullOrEmpty(searchType)) + return null; + + if (searchType == "execution-type") + { + var tag = (ExecutionTypeBox.SelectedItem as ComboBoxItem)?.Tag?.ToString(); + // "any" tag (first item) means no filter + if (string.IsNullOrEmpty(tag) || tag == "any") + return null; + // "Failed" bundles Aborted + Exception into an IN predicate + if (tag == "Failed") + return new QueryStoreFilter { ExecutionTypeDescs = ["Aborted", "Exception"] }; + return new QueryStoreFilter { ExecutionTypeDescs = [tag] }; + } + + var searchValue = SearchValueBox.Text?.Trim(); + if (string.IsNullOrEmpty(searchValue)) + return null; + + var filter = new QueryStoreFilter(); + + switch (searchType) + { + case "query-id" when long.TryParse(searchValue, out var qid): + filter.QueryId = qid; + break; + case "query-id": + StatusText.Text = "Invalid Query ID"; + return null; + case "plan-id" when long.TryParse(searchValue, out var pid): + filter.PlanId = pid; + break; + case "plan-id": + StatusText.Text = "Invalid Plan ID"; + return null; + case "query-hash": + filter.QueryHash = searchValue; + break; + case "plan-hash": + filter.QueryPlanHash = searchValue; + break; + case "module": + // Default to dbo schema if no schema specified, following sp_QuickieStore pattern + filter.ModuleName = searchValue.Contains('.') ? searchValue : $"dbo.{searchValue}"; + break; + default: + return null; + } + + return filter; + } + + private void SearchValue_KeyDown(object? sender, Avalonia.Input.KeyEventArgs e) + { + if (e.Key == Avalonia.Input.Key.Enter) + { + Fetch_Click(sender, e); + e.Handled = true; + } + } + + private void ClearSearch_Click(object? sender, RoutedEventArgs e) + { + SearchTypeBox.SelectedIndex = 0; + SearchValueBox.Text = ""; + // Resetting SearchTypeBox triggers SearchType_SelectionChanged which hides ExecutionTypePanel. + ExecutionTypeBox.SelectedIndex = 0; + } + + private void SetupColumnHeaders() + { + var cols = ResultsGrid.Columns; + // cols[0] = Expand column, cols[1] = Checkbox + SetColumnFilterButton(cols[2], "QueryId", "Query ID"); + SetColumnFilterButton(cols[3], "PlanId", "Plan ID"); + SetColumnFilterButton(cols[4], "QueryHash", "Query Hash"); + SetColumnFilterButton(cols[5], "PlanHash", "Plan Hash"); + SetColumnFilterButton(cols[6], "ModuleName", "Module"); + // cols[7] = WaitProfile (no filter button) + SetColumnFilterButton(cols[8], "LastExecuted", "Last Executed (Local)"); + SetColumnFilterButton(cols[9], "Executions", "Executions"); + SetColumnFilterButton(cols[10], "TotalCpu", "Total CPU (ms)"); + SetColumnFilterButton(cols[11], "AvgCpu", "Avg CPU (ms)"); + SetColumnFilterButton(cols[12], "TotalDuration", "Total Duration (ms)"); + SetColumnFilterButton(cols[13], "AvgDuration", "Avg Duration (ms)"); + SetColumnFilterButton(cols[14], "TotalReads", "Total Reads"); + SetColumnFilterButton(cols[15], "AvgReads", "Avg Reads"); + SetColumnFilterButton(cols[16], "TotalWrites", "Total Writes"); + SetColumnFilterButton(cols[17], "AvgWrites", "Avg Writes"); + SetColumnFilterButton(cols[18], "TotalPhysReads", "Total Physical Reads"); + SetColumnFilterButton(cols[19], "AvgPhysReads", "Avg Physical Reads"); + SetColumnFilterButton(cols[20], "TotalMemory", "Total Memory (MB)"); + SetColumnFilterButton(cols[21], "AvgMemory", "Avg Memory (MB)"); + SetColumnFilterButton(cols[22], "QueryText", "Query Text"); + } + + private void SetColumnFilterButton(DataGridColumn col, string columnId, string label) + { + var icon = new TextBlock + { + Text = "▽", + FontSize = 12, + VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center, + HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center, + }; + var btn = new Button + { + Content = icon, + Tag = columnId, + Width = 16, + Height = 16, + Padding = new Avalonia.Thickness(0), + Background = Brushes.Transparent, + BorderThickness = new Avalonia.Thickness(0), + VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center, + }; + btn.Click += ColumnFilter_Click; + ToolTip.SetTip(btn, "Click to filter"); + + var text = new TextBlock + { + Text = label, + FontWeight = FontWeight.Bold, + VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center, + Margin = new Avalonia.Thickness(4, 0, 0, 0), + }; + + var header = new StackPanel + { + Orientation = Avalonia.Layout.Orientation.Horizontal, + HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Left, + }; + header.Children.Add(btn); + header.Children.Add(text); + col.Header = header; + } + + private void EnsureFilterPopup() + { + if (_filterPopup != null) return; + _filterPopupContent = new ColumnFilterPopup(); + _filterPopup = new Popup + { + Child = _filterPopupContent, + IsLightDismissEnabled = true, + Placement = PlacementMode.Bottom, + }; + // Add to visual tree so DynamicResources resolve inside the popup + ((Grid)Content!).Children.Add(_filterPopup); + _filterPopupContent.FilterApplied += OnFilterApplied; + _filterPopupContent.FilterCleared += OnFilterCleared; + } + + private void ColumnFilter_Click(object? sender, RoutedEventArgs e) + { + if (sender is not Button button || button.Tag is not string columnId) return; + EnsureFilterPopup(); + _activeFilters.TryGetValue(columnId, out var existing); + _filterPopupContent!.Initialize(columnId, existing); + _filterPopup!.PlacementTarget = button; + _filterPopup.IsOpen = true; + } + + private void OnFilterApplied(object? sender, FilterAppliedEventArgs e) + { + _filterPopup!.IsOpen = false; + if (e.FilterState.IsActive) + _activeFilters[e.FilterState.ColumnName] = e.FilterState; + else + _activeFilters.Remove(e.FilterState.ColumnName); + ApplySortAndFilters(); + UpdateFilterButtonStyles(); + } + + private void OnFilterCleared(object? sender, EventArgs e) + { + _filterPopup!.IsOpen = false; + } + + private void UpdateFilterButtonStyles() + { + foreach (var col in ResultsGrid.Columns) + { + if (col.Header is not StackPanel sp) continue; + var btn = sp.Children.OfType public static ParsedPlan? LoadFromInternal(string planFileName) { - // Walk up from bin/Debug/net8.0 to find the repo root + // Walk up from bin/Debug/net10.0 to find the repo root var dir = new DirectoryInfo(AppContext.BaseDirectory); while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, ".internal"))) dir = dir.Parent; diff --git a/tests/PlanViewer.Core.Tests/PlanViewer.Core.Tests.csproj b/tests/PlanViewer.Core.Tests/PlanViewer.Core.Tests.csproj index 6b63900..474512c 100644 --- a/tests/PlanViewer.Core.Tests/PlanViewer.Core.Tests.csproj +++ b/tests/PlanViewer.Core.Tests/PlanViewer.Core.Tests.csproj @@ -1,7 +1,7 @@ - net8.0 + net10.0 enable enable @@ -11,7 +11,7 @@ - + From a0930555f7915f41dab0e29f834977f4f972307f Mon Sep 17 00:00:00 2001 From: Romain Ferraton <16419423+rferraton@users.noreply.github.com> Date: Mon, 18 May 2026 21:40:11 +0200 Subject: [PATCH 24/27] Query history improvments (#336) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix sort issues on the QueryHistory * What changed 1. QueryStoreGridControl.Selection.cs — ViewHistory_Click now walks up the visual tree to find the parent QuerySessionControl and calls AddHistorySubTab(), placing the history as a sub-tab alongside "Query Store — DB" and "QS Overview" (instead of at the top-level MainTabControl). 2. QuerySessionControl.QueryStore.cs — Added two new methods: • AddHistorySubTab() — creates a closeable sub-tab with the history control, including long-press (~500ms) to detach into a free-floating window • DetachHistorySubTabToWindow() — pops the history content into a standalone non-modal window (movable to another screen). When that window is minimized or closed, the content automatically re-docks back as a sub-tab 3. MainWindow.Tabs.cs — Removed the now-unused AddHistoryTab() method since history tabs no longer live at the top level User flow • View History → opens as a sub-tab next to Query Store / QS Overview • Long-press the history sub-tab → detaches into a free window (can move to another screen) • Minimize or close the free window → content returns to the sub-tab * What was added 1. QueryStoreService.FetchPlanByHashAsync() — New method that fetches a plan XML from sys.query_store_plan by query_plan_hash. The oldest parameter controls whether it returns the first (smallest plan_id) or last (largest plan_id) plan. 2. Context menu on QueryStoreHistoryControl — Right-clicking on the DataGrid or chart shows: • "Load the First Plan" — fetches the oldest plan for the selected plan hash • "Load the Last Plan" — fetches the most recent plan for the selected plan hash The selected plan hash is determined from either the grid selection or chart selection. 3. PlanLoadRequested event — Raised when the user picks a plan from the context menu. The parent QuerySessionControl subscribes to this event and opens the plan XML as a new sub-tab (using the existing AddPlanTab mechanism). User flow • Select a row in the history grid (or a dot on the chart) • Right-click → "Load the First Plan" or "Load the Last Plan" • The plan opens as a new sub-tab (e.g., "QS 42 / 17") * Code review : Bugs Fixed 1. Shared ContextMenu — BuildContextMenu() now creates separate ContextMenu instances for DataGrid and Chart via CreatePlanContextMenu() 2. Silent catch — LoadPlanFromSelection now shows errors in StatusText with catch (Exception ex) 3. Wrong type check — Close_Click now checks is not PlanViewer.App.MainWindow instead of IClassicDesktopStyleApplicationLifetime 4. Event handler leak — AddHistorySubTab now unsubscribes before subscribing: -= OnHistoryPlanLoadRequested before += Improvements 5. Loading feedback — Shows "Loading plan…" / "Plan not found" / error in StatusText 6. Disable menu when no selection — Opening handler disables items when GetSelectedPlanHash() is null 7. IndexOf — Added clarifying comment (list is <500 items, no better API available) 8. ScrollIntoView — Moved after ItemsSource reset so the target row exists 9. Long-press duplication — Extracted TabHeaderLongPressBehavior helper, used in both MainWindow.Tabs.cs and QuerySessionControl.QueryStore.cs 10. HistoryPlanLoadEventArgs — Moved to its own file Nits 11. Removed unnecessary ?. on non-nullable orderBy parameter 12. Changed bare catch to catch (Exception) in tooltip handler 13. Changed bare catch to catch (Exception ex) in LoadPlanFromSelection * When the MainWindow closes, it now iterates all open windows via IClassicDesktopStyleApplicationLifetime.Windows and closes any that aren't the MainWindow itself. This ensures all detached free-floating windows (from tab detach, history detach, advice windows, etc.) are closed when the app's main window is closed. * Must-fix fixed * use pin/unpin button to fix ux-must-fix * fix error truncation to 80 chars : truncation removed * fix most remaining nice to have * add spinner and cancel for history fetches --- .../Controls/HistoryPlanLoadEventArgs.cs | 17 + .../QuerySessionControl.QueryStore.cs | 228 ++-- .../QueryStoreGridControl.Selection.cs | 27 +- .../Controls/QueryStoreHistoryControl.axaml | 163 +++ .../QueryStoreHistoryControl.axaml.cs | 1100 +++++++++++++++++ .../Dialogs/QueryStoreHistoryWindow.axaml | 152 +-- .../Dialogs/QueryStoreHistoryWindow.axaml.cs | 992 +-------------- .../Helpers/DetachedWindowHelper.cs | 93 ++ src/PlanViewer.App/MainWindow.Tabs.cs | 51 + src/PlanViewer.App/MainWindow.axaml.cs | 38 +- .../Services/QueryStoreService.cs | 48 + 11 files changed, 1704 insertions(+), 1205 deletions(-) create mode 100644 src/PlanViewer.App/Controls/HistoryPlanLoadEventArgs.cs create mode 100644 src/PlanViewer.App/Controls/QueryStoreHistoryControl.axaml create mode 100644 src/PlanViewer.App/Controls/QueryStoreHistoryControl.axaml.cs create mode 100644 src/PlanViewer.App/Helpers/DetachedWindowHelper.cs diff --git a/src/PlanViewer.App/Controls/HistoryPlanLoadEventArgs.cs b/src/PlanViewer.App/Controls/HistoryPlanLoadEventArgs.cs new file mode 100644 index 0000000..5080f40 --- /dev/null +++ b/src/PlanViewer.App/Controls/HistoryPlanLoadEventArgs.cs @@ -0,0 +1,17 @@ +using System; +using PlanViewer.Core.Models; + +namespace PlanViewer.App.Controls; + +/// +/// Event args for when a plan is loaded from the history context menu. +/// +public class HistoryPlanLoadEventArgs : EventArgs +{ + public QueryStorePlan Plan { get; } + + public HistoryPlanLoadEventArgs(QueryStorePlan plan) + { + Plan = plan; + } +} diff --git a/src/PlanViewer.App/Controls/QuerySessionControl.QueryStore.cs b/src/PlanViewer.App/Controls/QuerySessionControl.QueryStore.cs index bf4d135..4d68b49 100644 --- a/src/PlanViewer.App/Controls/QuerySessionControl.QueryStore.cs +++ b/src/PlanViewer.App/Controls/QuerySessionControl.QueryStore.cs @@ -19,6 +19,7 @@ using AvaloniaEdit.TextMate; using Microsoft.Data.SqlClient; using PlanViewer.App.Dialogs; +using PlanViewer.App.Helpers; using PlanViewer.App.Services; using PlanViewer.Core.Interfaces; using PlanViewer.Core.Models; @@ -38,31 +39,15 @@ private bool HasQueryStoreTab() public void TriggerQueryStore() => QueryStore_Click(null, new RoutedEventArgs()); - private async void QueryStoreOverview_Click(object? sender, RoutedEventArgs e) + /// + /// Creates a sub-tab with a standard header (label + optional extra buttons + close button). + /// Returns the TabItem. The close button removes the tab from SubTabControl. + /// + private TabItem CreateSubTab(string label, Control content, Action? onClose = null, params Button[] extraButtons) { - if (_serverConnection == null || _connectionString == null) - { - await ShowConnectionDialogAsync(); - if (_serverConnection == null || _connectionString == null) - return; - } - - SetStatus("Loading Query Store Overview..."); - - var supportsWaitStats = _serverMetadata?.SupportsQueryStoreWaitStats ?? false; - var overview = new QueryStoreOverviewControl(_serverConnection, _credentialService, - supportsWaitStats: supportsWaitStats); - overview.DrillDownRequested += async (_, args) => - { - // Open a single-database Query Store tab directly (no connection dialog) - _selectedDatabase = args.Database; - _connectionString = _serverConnection!.GetConnectionString(_credentialService, args.Database); - await OpenQueryStoreForDatabaseAsync(args.Database, args.StartUtc, args.EndUtc); - }; - var headerText = new TextBlock { - Text = "QS Overview", + Text = label, VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center, FontSize = 12 }; @@ -73,7 +58,7 @@ private async void QueryStoreOverview_Click(object? sender, RoutedEventArgs e) MinWidth = 22, MinHeight = 22, Width = 22, Height = 22, Padding = new Avalonia.Thickness(0), FontSize = 11, - Margin = new Avalonia.Thickness(6, 0, 0, 0), + Margin = new Avalonia.Thickness(2, 0, 0, 0), Background = Brushes.Transparent, BorderThickness = new Avalonia.Thickness(0), Foreground = new SolidColorBrush(Color.FromRgb(0xE4, 0xE6, 0xEB)), @@ -85,17 +70,58 @@ private async void QueryStoreOverview_Click(object? sender, RoutedEventArgs e) var header = new StackPanel { Orientation = Avalonia.Layout.Orientation.Horizontal, - Children = { headerText, closeBtn } + Background = Brushes.Transparent }; + header.Children.Add(headerText); + foreach (var btn in extraButtons) + header.Children.Add(btn); + header.Children.Add(closeBtn); - var tab = new TabItem { Header = header, Content = overview }; + var tab = new TabItem { Header = header, Content = content }; closeBtn.Tag = tab; closeBtn.Click += (s, _) => { if (s is Button btn && btn.Tag is TabItem t) + { + onClose?.Invoke(t); SubTabControl.Items.Remove(t); + } + }; + + return tab; + } + + /// Gets the header TextBlock from a sub-tab created via CreateSubTab. + private static TextBlock? GetSubTabHeaderText(TabItem tab) + { + if (tab.Header is StackPanel sp && sp.Children.Count > 0 && sp.Children[0] is TextBlock tb) + return tb; + return null; + } + + private async void QueryStoreOverview_Click(object? sender, RoutedEventArgs e) + { + if (_serverConnection == null || _connectionString == null) + { + await ShowConnectionDialogAsync(); + if (_serverConnection == null || _connectionString == null) + return; + } + + SetStatus("Loading Query Store Overview..."); + + var supportsWaitStats = _serverMetadata?.SupportsQueryStoreWaitStats ?? false; + var overview = new QueryStoreOverviewControl(_serverConnection, _credentialService, + supportsWaitStats: supportsWaitStats); + overview.DrillDownRequested += async (_, args) => + { + // Open a single-database Query Store tab directly (no connection dialog) + _selectedDatabase = args.Database; + _connectionString = _serverConnection!.GetConnectionString(_credentialService, args.Database); + await OpenQueryStoreForDatabaseAsync(args.Database, args.StartUtc, args.EndUtc); }; + var tab = CreateSubTab("QS Overview", overview); SubTabControl.Items.Add(tab); SubTabControl.SelectedItem = tab; @@ -106,7 +132,7 @@ private async void QueryStoreOverview_Click(object? sender, RoutedEventArgs e) } catch (Exception ex) { - SetStatus(ex.Message.Length > 80 ? ex.Message[..80] + "..." : ex.Message, autoClear: false); + SetStatus(ex.Message, autoClear: false); } } @@ -127,7 +153,7 @@ private async Task OpenQueryStoreForDatabaseAsync(string database, DateTime? ini } catch (Exception ex) { - SetStatus(ex.Message.Length > 80 ? ex.Message[..80] + "..." : ex.Message, autoClear: false); + SetStatus(ex.Message, autoClear: false); return; } @@ -152,41 +178,11 @@ private async Task OpenQueryStoreForDatabaseAsync(string database, DateTime? ini grid.SetInitialTimeRange(initialStartUtc.Value, initialEndUtc.Value); grid.PlansSelected += OnQueryStorePlansSelected; - var headerText = new TextBlock - { - Text = $"Query Store — {database}", - VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center, - FontSize = 12 - }; - grid.DatabaseChanged += (_, db) => headerText.Text = $"Query Store — {db}"; - - var closeBtn = new Button - { - Content = "\u2715", - MinWidth = 22, MinHeight = 22, Width = 22, Height = 22, - Padding = new Avalonia.Thickness(0), - FontSize = 11, - Margin = new Avalonia.Thickness(6, 0, 0, 0), - Background = Brushes.Transparent, - BorderThickness = new Avalonia.Thickness(0), - Foreground = new SolidColorBrush(Color.FromRgb(0xE4, 0xE6, 0xEB)), - VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center, - HorizontalContentAlignment = HorizontalAlignment.Center, - VerticalContentAlignment = VerticalAlignment.Center - }; - - var header = new StackPanel - { - Orientation = Avalonia.Layout.Orientation.Horizontal, - Children = { headerText, closeBtn } - }; - - var tab = new TabItem { Header = header, Content = grid }; - closeBtn.Tag = tab; - closeBtn.Click += (s, _) => + var tab = CreateSubTab($"Query Store — {database}", grid); + grid.DatabaseChanged += (_, db) => { - if (s is Button btn && btn.Tag is TabItem t) - SubTabControl.Items.Remove(t); + if (GetSubTabHeaderText(tab) is TextBlock tb) + tb.Text = $"Query Store — {db}"; }; SubTabControl.Items.Add(tab); @@ -244,62 +240,112 @@ private async void QueryStore_Click(object? sender, RoutedEventArgs e) _selectedDatabase!, databases, supportsWaitStats); grid.PlansSelected += OnQueryStorePlansSelected; - var headerText = new TextBlock - { - Text = $"Query Store — {_selectedDatabase}", - VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center, - FontSize = 12 - }; - + var tab = CreateSubTab($"Query Store — {_selectedDatabase}", grid); // Update tab header when database is changed via the grid's picker grid.DatabaseChanged += (_, db) => { - headerText.Text = $"Query Store — {db}"; + if (GetSubTabHeaderText(tab) is TextBlock tb) + tb.Text = $"Query Store — {db}"; }; - var closeBtn = new Button + SubTabControl.Items.Add(tab); + SubTabControl.SelectedItem = tab; + } + + private void OnQueryStorePlansSelected(object? sender, List plans) + { + foreach (var qsPlan in plans) { - Content = "\u2715", + var tabLabel = $"QS {qsPlan.QueryId} / {qsPlan.PlanId}"; + AddPlanTab(qsPlan.PlanXml, qsPlan.QueryText, estimated: true, labelOverride: tabLabel); + } + + SetStatus($"{plans.Count} Query Store plans loaded"); + HumanAdviceButton.IsEnabled = true; + RobotAdviceButton.IsEnabled = true; + } + + /// + /// Adds a Query Store History control as a sub-tab in this session. + /// Supports long-press to detach into a free-floating window. + /// + public void AddHistorySubTab(string label, QueryStoreHistoryControl control) + { + // Wire up plan load from context menu (unsubscribe first to prevent leaks on re-dock) + control.PlanLoadRequested -= OnHistoryPlanLoadRequested; + control.PlanLoadRequested += OnHistoryPlanLoadRequested; + + var detachBtn = new Button + { + Content = "↗", MinWidth = 22, MinHeight = 22, Width = 22, Height = 22, Padding = new Avalonia.Thickness(0), FontSize = 11, - Margin = new Avalonia.Thickness(6, 0, 0, 0), + Margin = new Avalonia.Thickness(4, 0, 0, 0), Background = Brushes.Transparent, BorderThickness = new Avalonia.Thickness(0), - Foreground = new SolidColorBrush(Color.FromRgb(0xE4, 0xE6, 0xEB)), + Foreground = new SolidColorBrush(Color.FromRgb(0xA0, 0xA0, 0xA0)), VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center, HorizontalContentAlignment = HorizontalAlignment.Center, VerticalContentAlignment = VerticalAlignment.Center }; + Avalonia.Controls.ToolTip.SetTip(detachBtn, "Detach to Window"); - var header = new StackPanel - { - Orientation = Avalonia.Layout.Orientation.Horizontal, - Children = { headerText, closeBtn } - }; + var tab = CreateSubTab(label, control, + onClose: t => { if (t.Content is QueryStoreHistoryControl hc) hc.CancelFetch(); }, + detachBtn); - var tab = new TabItem { Header = header, Content = grid }; - closeBtn.Tag = tab; - closeBtn.Click += (s, _) => + detachBtn.Tag = tab; + detachBtn.Click += (s, _) => { if (s is Button btn && btn.Tag is TabItem t) - SubTabControl.Items.Remove(t); + DetachHistorySubTabToWindow(t); }; SubTabControl.Items.Add(tab); SubTabControl.SelectedItem = tab; } - private void OnQueryStorePlansSelected(object? sender, List plans) + private void OnHistoryPlanLoadRequested(object? sender, HistoryPlanLoadEventArgs e) { - foreach (var qsPlan in plans) - { - var tabLabel = $"QS {qsPlan.QueryId} / {qsPlan.PlanId}"; - AddPlanTab(qsPlan.PlanXml, qsPlan.QueryText, estimated: true, labelOverride: tabLabel); - } + var plan = e.Plan; + var tabLabel = $"QS {plan.QueryId} / {plan.PlanId}"; + AddPlanTab(plan.PlanXml, plan.QueryText, estimated: true, labelOverride: tabLabel); + } - SetStatus($"{plans.Count} Query Store plans loaded"); - HumanAdviceButton.IsEnabled = true; - RobotAdviceButton.IsEnabled = true; + /// + /// Detaches a history sub-tab into a standalone free-floating window. + /// Close = destroy. A Re-dock button allows explicit return to sub-tabs. + /// + private void DetachHistorySubTabToWindow(TabItem tab) + { + var content = tab.Content as QueryStoreHistoryControl; + if (content == null) return; + + var tabLabel = GetSubTabHeaderText(tab)?.Text ?? "History"; + + // Remove from sub-tabs + SubTabControl.Items.Remove(tab); + tab.Content = null; + + var mainWindow = Avalonia.Controls.TopLevel.GetTopLevel(this) as Window; + + content.ShowCloseButton(false); + + DetachedWindowHelper.ShowDetached( + content, + title: tabLabel, + icon: mainWindow?.Icon, + backgroundBrush: (Avalonia.Media.IBrush?)this.FindResource("BackgroundBrush"), + onRedock: c => + { + if (mainWindow is not MainWindow mw || !mw.IsShuttingDown) + AddHistorySubTab(tabLabel, (QueryStoreHistoryControl)c); + }, + onClosing: c => + { + if (c is QueryStoreHistoryControl hc) + hc.CancelFetch(); + }); } } diff --git a/src/PlanViewer.App/Controls/QueryStoreGridControl.Selection.cs b/src/PlanViewer.App/Controls/QueryStoreGridControl.Selection.cs index a910531..d9c63ea 100644 --- a/src/PlanViewer.App/Controls/QueryStoreGridControl.Selection.cs +++ b/src/PlanViewer.App/Controls/QueryStoreGridControl.Selection.cs @@ -87,14 +87,14 @@ private static List CollectLeafPlans(QueryStoreRow row) return plans; } - private async void ViewHistory_Click(object? sender, RoutedEventArgs e) + private void ViewHistory_Click(object? sender, RoutedEventArgs e) { if (ResultsGrid.SelectedItem is not QueryStoreRow row) return; if (string.IsNullOrEmpty(row.QueryHash)) return; var metricTag = QueryStoreHistoryWindow.MapOrderByToMetricTag(_lastFetchedOrderBy); - var window = new QueryStoreHistoryWindow( + var control = new QueryStoreHistoryControl( _connectionString, row.QueryHash, row.FullQueryText, @@ -104,11 +104,28 @@ private async void ViewHistory_Click(object? sender, RoutedEventArgs e) slicerEndUtc: _slicerEndUtc, slicerDaysBack: _slicerDaysBack); - var topLevel = Avalonia.Controls.TopLevel.GetTopLevel(this); - if (topLevel is Window parentWindow) - await window.ShowDialog(parentWindow); + var shortHash = row.QueryHash.Length > 8 ? row.QueryHash[..8] + "…" : row.QueryHash; + + // Walk up the visual tree to find the parent QuerySessionControl + var session = this.FindAncestorOfType(); + if (session != null) + { + session.AddHistorySubTab($"History: {shortHash}", control); + } else + { + // Fallback: open as standalone window + var window = new QueryStoreHistoryWindow( + _connectionString, + row.QueryHash, + row.FullQueryText, + _database, + initialMetricTag: metricTag, + slicerStartUtc: _slicerStartUtc, + slicerEndUtc: _slicerEndUtc, + slicerDaysBack: _slicerDaysBack); window.Show(); + } } private void ContextMenu_Opening(object? sender, System.ComponentModel.CancelEventArgs e) diff --git a/src/PlanViewer.App/Controls/QueryStoreHistoryControl.axaml b/src/PlanViewer.App/Controls/QueryStoreHistoryControl.axaml new file mode 100644 index 0000000..adda15b --- /dev/null +++ b/src/PlanViewer.App/Controls/QueryStoreHistoryControl.axaml @@ -0,0 +1,163 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -