diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 870464a..2c094a6 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -1,148 +1,193 @@
-name: Release
-
-on:
- pull_request:
- branches: [main]
- types: [closed]
-
-permissions:
- contents: write
-
-jobs:
- release:
- if: github.event.pull_request.merged == true && github.event.pull_request.head.ref == 'dev'
- runs-on: windows-latest
-
- steps:
- - uses: actions/checkout@v4
-
- - name: Get version
- id: version
- shell: pwsh
- run: |
- $version = ([xml](Get-Content src/PlanViewer.App/PlanViewer.App.csproj)).Project.PropertyGroup.Version | Where-Object { $_ }
- echo "VERSION=$version" >> $env:GITHUB_OUTPUT
-
- - name: Check if release already exists
- id: check
- shell: bash
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: |
- if gh release view "v${{ steps.version.outputs.VERSION }}" > /dev/null 2>&1; then
- echo "EXISTS=true" >> $GITHUB_OUTPUT
- else
- echo "EXISTS=false" >> $GITHUB_OUTPUT
- fi
-
- - name: Create release
- if: steps.check.outputs.EXISTS == 'false'
- shell: bash
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: |
- gh release create "v${{ steps.version.outputs.VERSION }}" --title "v${{ steps.version.outputs.VERSION }}" --generate-notes --target main
-
- - name: Setup .NET 8.0
- if: steps.check.outputs.EXISTS == 'false'
- uses: actions/setup-dotnet@v4
- with:
- dotnet-version: 8.0.x
-
- - name: Build and test
- if: steps.check.outputs.EXISTS == 'false'
- run: |
- dotnet restore
- dotnet build -c Release
- dotnet test tests/PlanViewer.Core.Tests/PlanViewer.Core.Tests.csproj -c Release --no-build --verbosity normal
-
- - name: Publish App (all platforms)
- if: steps.check.outputs.EXISTS == 'false'
- run: |
- dotnet publish src/PlanViewer.App/PlanViewer.App.csproj -c Release -r win-x64 --self-contained -o publish/win-x64
- dotnet publish src/PlanViewer.App/PlanViewer.App.csproj -c Release -r linux-x64 --self-contained -o publish/linux-x64
- dotnet publish src/PlanViewer.App/PlanViewer.App.csproj -c Release -r osx-x64 --self-contained -o publish/osx-x64
- dotnet publish src/PlanViewer.App/PlanViewer.App.csproj -c Release -r osx-arm64 --self-contained -o publish/osx-arm64
-
- - name: Create Velopack release (Windows)
- if: steps.check.outputs.EXISTS == 'false'
- shell: pwsh
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- VERSION: ${{ steps.version.outputs.VERSION }}
- run: |
- dotnet tool install -g vpk
- New-Item -ItemType Directory -Force -Path releases/velopack
-
- # Download previous release for delta generation
- vpk download github --repoUrl https://github.com/${{ github.repository }} --channel win -o releases/velopack --token $env:GH_TOKEN
-
- # Pack Windows release
- vpk pack -u PerformanceStudio -v $env:VERSION -p publish/win-x64 -e PlanViewer.App.exe -o releases/velopack --channel win
-
- - name: Package and upload
- if: steps.check.outputs.EXISTS == 'false'
- shell: pwsh
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- VERSION: ${{ steps.version.outputs.VERSION }}
- run: |
- New-Item -ItemType Directory -Force -Path releases
-
- # Package Windows and Linux as flat zips
- foreach ($rid in @('win-x64', 'linux-x64')) {
- if (Test-Path 'README.md') { Copy-Item 'README.md' "publish/$rid/" }
- if (Test-Path 'LICENSE') { Copy-Item 'LICENSE' "publish/$rid/" }
- Compress-Archive -Path "publish/$rid/*" -DestinationPath "releases/PerformanceStudio-$rid.zip" -Force
- }
-
- # Package macOS as proper .app bundles
- foreach ($rid in @('osx-x64', 'osx-arm64')) {
- $appName = "PerformanceStudio.app"
- $bundleDir = "publish/$rid-bundle/$appName"
-
- # Create .app bundle structure
- New-Item -ItemType Directory -Force -Path "$bundleDir/Contents/MacOS"
- New-Item -ItemType Directory -Force -Path "$bundleDir/Contents/Resources"
-
- # Copy all published files into Contents/MacOS
- Copy-Item -Path "publish/$rid/*" -Destination "$bundleDir/Contents/MacOS/" -Recurse
-
- # Move Info.plist to Contents/ (it was copied to MacOS/ with the publish output)
- if (Test-Path "$bundleDir/Contents/MacOS/Info.plist") {
- Move-Item -Path "$bundleDir/Contents/MacOS/Info.plist" -Destination "$bundleDir/Contents/Info.plist" -Force
- }
-
- # Update version in Info.plist to match csproj
- $plist = Get-Content "$bundleDir/Contents/Info.plist" -Raw
- $plist = $plist -replace '(CFBundleVersion\s*)[^<]*()', "`${1}$env:VERSION`${2}"
- $plist = $plist -replace '(CFBundleShortVersionString\s*)[^<]*()', "`${1}$env:VERSION`${2}"
- Set-Content -Path "$bundleDir/Contents/Info.plist" -Value $plist -NoNewline
-
- # Move icon to Contents/Resources
- if (Test-Path "$bundleDir/Contents/MacOS/EDD.icns") {
- Move-Item -Path "$bundleDir/Contents/MacOS/EDD.icns" -Destination "$bundleDir/Contents/Resources/EDD.icns" -Force
- }
-
- # Add README and LICENSE alongside the .app bundle
- $wrapperDir = "publish/$rid-bundle"
- if (Test-Path 'README.md') { Copy-Item 'README.md' "$wrapperDir/" }
- if (Test-Path 'LICENSE') { Copy-Item 'LICENSE' "$wrapperDir/" }
-
- Compress-Archive -Path "$wrapperDir/*" -DestinationPath "releases/PerformanceStudio-$rid.zip" -Force
- }
-
- # Checksums (zips only, Velopack has its own checksums)
- $checksums = Get-ChildItem releases/*.zip | ForEach-Object {
- $hash = (Get-FileHash $_.FullName -Algorithm SHA256).Hash.ToLower()
- "$hash $($_.Name)"
- }
- $checksums | Out-File -FilePath releases/SHA256SUMS.txt -Encoding utf8
- Write-Host "Checksums:"
- $checksums | ForEach-Object { Write-Host $_ }
-
- # Upload zips + checksums
- gh release upload "v$env:VERSION" releases/*.zip releases/SHA256SUMS.txt --clobber
-
- # Upload Velopack artifacts
- vpk upload github --repoUrl https://github.com/${{ github.repository }} --channel win -o releases/velopack --releaseName "v$env:VERSION" --tag "v$env:VERSION" --merge --token $env:GH_TOKEN
+name: Release
+
+on:
+ pull_request:
+ branches: [main]
+ types: [closed]
+
+permissions:
+ contents: write
+ id-token: write
+ actions: read
+
+jobs:
+ release:
+ if: github.event.pull_request.merged == true && github.event.pull_request.head.ref == 'dev'
+ runs-on: windows-latest
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Get version
+ id: version
+ shell: pwsh
+ run: |
+ $version = ([xml](Get-Content src/PlanViewer.App/PlanViewer.App.csproj)).Project.PropertyGroup.Version | Where-Object { $_ }
+ echo "VERSION=$version" >> $env:GITHUB_OUTPUT
+
+ - name: Check if release already exists
+ id: check
+ shell: bash
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ if gh release view "v${{ steps.version.outputs.VERSION }}" > /dev/null 2>&1; then
+ echo "EXISTS=true" >> $GITHUB_OUTPUT
+ else
+ echo "EXISTS=false" >> $GITHUB_OUTPUT
+ fi
+
+ - name: Create release
+ if: steps.check.outputs.EXISTS == 'false'
+ shell: bash
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ gh release create "v${{ steps.version.outputs.VERSION }}" --title "v${{ steps.version.outputs.VERSION }}" --generate-notes --target main
+
+ - name: Setup .NET 8.0
+ if: steps.check.outputs.EXISTS == 'false'
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: 8.0.x
+
+ - name: Build and test
+ if: steps.check.outputs.EXISTS == 'false'
+ run: |
+ dotnet restore
+ dotnet build -c Release
+ dotnet test tests/PlanViewer.Core.Tests/PlanViewer.Core.Tests.csproj -c Release --no-build --verbosity normal
+
+ - name: Publish App (all platforms)
+ if: steps.check.outputs.EXISTS == 'false'
+ run: |
+ dotnet publish src/PlanViewer.App/PlanViewer.App.csproj -c Release -r win-x64 --self-contained -o publish/win-x64
+ dotnet publish src/PlanViewer.App/PlanViewer.App.csproj -c Release -r linux-x64 --self-contained -o publish/linux-x64
+ dotnet publish src/PlanViewer.App/PlanViewer.App.csproj -c Release -r osx-x64 --self-contained -o publish/osx-x64
+ dotnet publish src/PlanViewer.App/PlanViewer.App.csproj -c Release -r osx-arm64 --self-contained -o publish/osx-arm64
+
+ # ── SignPath code signing (Windows only, skipped if secret not configured) ──
+ - name: Check if signing is configured
+ if: steps.check.outputs.EXISTS == 'false'
+ id: signing
+ shell: bash
+ run: |
+ if [ -n "${{ secrets.SIGNPATH_API_TOKEN }}" ]; then
+ echo "ENABLED=true" >> $GITHUB_OUTPUT
+ else
+ echo "ENABLED=false" >> $GITHUB_OUTPUT
+ echo "::warning::SIGNPATH_API_TOKEN not configured — releasing unsigned binaries"
+ fi
+
+ - name: Upload Windows build for signing
+ if: steps.check.outputs.EXISTS == 'false' && steps.signing.outputs.ENABLED == 'true'
+ id: upload-unsigned
+ uses: actions/upload-artifact@v4
+ with:
+ name: App-unsigned
+ path: publish/win-x64/
+
+ - name: Sign Windows build
+ if: steps.check.outputs.EXISTS == 'false' && steps.signing.outputs.ENABLED == 'true'
+ uses: signpath/github-action-submit-signing-request@v1
+ with:
+ api-token: '${{ secrets.SIGNPATH_API_TOKEN }}'
+ organization-id: '7969f8b6-d946-4a74-9bac-a55856d8b8e0'
+ project-slug: 'PerformanceStudio'
+ signing-policy-slug: 'test-signing'
+ artifact-configuration-slug: 'App'
+ github-artifact-id: '${{ steps.upload-unsigned.outputs.artifact-id }}'
+ wait-for-completion: true
+ output-artifact-directory: 'signed/win-x64'
+
+ - name: Replace unsigned Windows build with signed
+ if: steps.check.outputs.EXISTS == 'false' && steps.signing.outputs.ENABLED == 'true'
+ shell: pwsh
+ run: |
+ Remove-Item -Recurse -Force publish/win-x64
+ Copy-Item -Recurse signed/win-x64 publish/win-x64
+
+ # ── Velopack (uses signed Windows binaries) ───────────────────────
+ - name: Create Velopack release (Windows)
+ if: steps.check.outputs.EXISTS == 'false'
+ shell: pwsh
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ VERSION: ${{ steps.version.outputs.VERSION }}
+ run: |
+ dotnet tool install -g vpk
+ New-Item -ItemType Directory -Force -Path releases/velopack
+
+ # Download previous release for delta generation
+ vpk download github --repoUrl https://github.com/${{ github.repository }} --channel win -o releases/velopack --token $env:GH_TOKEN
+
+ # Pack Windows release (now signed)
+ vpk pack -u PerformanceStudio -v $env:VERSION -p publish/win-x64 -e PlanViewer.App.exe -o releases/velopack --channel win
+
+ # ── Package and upload ────────────────────────────────────────────
+ - name: Package and upload
+ if: steps.check.outputs.EXISTS == 'false'
+ shell: pwsh
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ VERSION: ${{ steps.version.outputs.VERSION }}
+ run: |
+ New-Item -ItemType Directory -Force -Path releases
+
+ # Package Windows (signed) and Linux as flat zips
+ foreach ($rid in @('win-x64', 'linux-x64')) {
+ if (Test-Path 'README.md') { Copy-Item 'README.md' "publish/$rid/" }
+ if (Test-Path 'LICENSE') { Copy-Item 'LICENSE' "publish/$rid/" }
+ Compress-Archive -Path "publish/$rid/*" -DestinationPath "releases/PerformanceStudio-$rid.zip" -Force
+ }
+
+ # Package macOS as proper .app bundles
+ foreach ($rid in @('osx-x64', 'osx-arm64')) {
+ $appName = "PerformanceStudio.app"
+ $bundleDir = "publish/$rid-bundle/$appName"
+
+ # Create .app bundle structure
+ New-Item -ItemType Directory -Force -Path "$bundleDir/Contents/MacOS"
+ New-Item -ItemType Directory -Force -Path "$bundleDir/Contents/Resources"
+
+ # Copy all published files into Contents/MacOS
+ Copy-Item -Path "publish/$rid/*" -Destination "$bundleDir/Contents/MacOS/" -Recurse
+
+ # Move Info.plist to Contents/ (it was copied to MacOS/ with the publish output)
+ if (Test-Path "$bundleDir/Contents/MacOS/Info.plist") {
+ Move-Item -Path "$bundleDir/Contents/MacOS/Info.plist" -Destination "$bundleDir/Contents/Info.plist" -Force
+ }
+
+ # Update version in Info.plist to match csproj
+ $plist = Get-Content "$bundleDir/Contents/Info.plist" -Raw
+ $plist = $plist -replace '(CFBundleVersion\s*)[^<]*()', "`${1}$env:VERSION`${2}"
+ $plist = $plist -replace '(CFBundleShortVersionString\s*)[^<]*()', "`${1}$env:VERSION`${2}"
+ Set-Content -Path "$bundleDir/Contents/Info.plist" -Value $plist -NoNewline
+
+ # Move icon to Contents/Resources
+ if (Test-Path "$bundleDir/Contents/MacOS/EDD.icns") {
+ Move-Item -Path "$bundleDir/Contents/MacOS/EDD.icns" -Destination "$bundleDir/Contents/Resources/EDD.icns" -Force
+ }
+
+ # Add README and LICENSE alongside the .app bundle
+ $wrapperDir = "publish/$rid-bundle"
+ if (Test-Path 'README.md') { Copy-Item 'README.md' "$wrapperDir/" }
+ if (Test-Path 'LICENSE') { Copy-Item 'LICENSE' "$wrapperDir/" }
+
+ Compress-Archive -Path "$wrapperDir/*" -DestinationPath "releases/PerformanceStudio-$rid.zip" -Force
+ }
+
+ # Checksums (zips only, Velopack has its own checksums)
+ $checksums = Get-ChildItem releases/*.zip | ForEach-Object {
+ $hash = (Get-FileHash $_.FullName -Algorithm SHA256).Hash.ToLower()
+ "$hash $($_.Name)"
+ }
+ $checksums | Out-File -FilePath releases/SHA256SUMS.txt -Encoding utf8
+ Write-Host "Checksums:"
+ $checksums | ForEach-Object { Write-Host $_ }
+
+ # Upload zips + checksums
+ gh release upload "v$env:VERSION" releases/*.zip releases/SHA256SUMS.txt --clobber
+
+ # Upload Velopack artifacts
+ vpk upload github --repoUrl https://github.com/${{ github.repository }} --channel win -o releases/velopack --releaseName "v$env:VERSION" --tag "v$env:VERSION" --merge --token $env:GH_TOKEN
diff --git a/README.md b/README.md
index e5a3b71..19a5174 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,21 @@
# Performance Studio
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
A cross-platform SQL Server execution plan analyzer with built-in MCP server for AI-assisted analysis. Parses `.sqlplan` XML, identifies performance problems, suggests missing indexes, and provides actionable warnings — from the command line or a desktop GUI.
Built for developers and DBAs who want fast, automated plan analysis without clicking through SSMS.
diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.axaml b/src/PlanViewer.App/Controls/PlanViewerControl.axaml
index db9d492..1eec781 100644
--- a/src/PlanViewer.App/Controls/PlanViewerControl.axaml
+++ b/src/PlanViewer.App/Controls/PlanViewerControl.axaml
@@ -14,6 +14,20 @@
BorderBrush="{DynamicResource BorderBrush}" BorderThickness="0,0,0,1">
+
+
+
+
+
@@ -228,6 +242,7 @@
+
diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs b/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs
index 6851fdb..1051b23 100644
--- a/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs
+++ b/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs
@@ -15,7 +15,12 @@
using Avalonia.Controls.Primitives;
using Avalonia.Controls.Templates;
using Avalonia.Platform.Storage;
+using AvaloniaEdit.TextMate;
+using Microsoft.Data.SqlClient;
+using PlanViewer.App.Dialogs;
+using PlanViewer.Core.Interfaces;
using PlanViewer.App.Helpers;
+using PlanViewer.App.Services;
using PlanViewer.App.Mcp;
using PlanViewer.Core.Models;
using PlanViewer.Core.Output;
@@ -167,10 +172,98 @@ public ServerMetadata? Metadata
}
}
+ ///
+ /// Connection string for schema lookups. Set when the plan was loaded from a connected session.
+ ///
+ public string? ConnectionString { get; set; }
+
+ // Connection state for plans that connect via the toolbar
+ private ServerConnection? _planConnection;
+ private ICredentialService? _planCredentialService;
+ private ConnectionStore? _planConnectionStore;
+ private string? _planSelectedDatabase;
+
+ ///
+ /// Provide credential service and connection store so the plan viewer can show a connection dialog.
+ ///
+ public void SetConnectionServices(ICredentialService credentialService, ConnectionStore connectionStore)
+ {
+ _planCredentialService = credentialService;
+ _planConnectionStore = connectionStore;
+ }
+
+ ///
+ /// Update the connection UI to reflect an active connection (used when connection is inherited).
+ ///
+ public void SetConnectionStatus(string serverName, string? database)
+ {
+ PlanServerLabel.Text = serverName;
+ PlanServerLabel.Foreground = Brushes.LimeGreen;
+ PlanConnectButton.Content = "Reconnect";
+ if (database != null)
+ _planSelectedDatabase = database;
+ }
+
// Events for MainWindow to wire up advice/repro actions
public event EventHandler? HumanAdviceRequested;
public event EventHandler? RobotAdviceRequested;
public event EventHandler? CopyReproRequested;
+ public event EventHandler? OpenInEditorRequested;
+
+ ///
+ /// Navigates to a specific plan node by ID: selects it, zooms to show it,
+ /// and scrolls to center it in the viewport.
+ ///
+ public void NavigateToNode(int nodeId)
+ {
+ // Find the Border for this node
+ Border? targetBorder = null;
+ PlanNode? targetNode = null;
+ foreach (var (border, node) in _nodeBorderMap)
+ {
+ if (node.NodeId == nodeId)
+ {
+ targetBorder = border;
+ targetNode = node;
+ break;
+ }
+ }
+
+ if (targetBorder == null || targetNode == null)
+ return;
+
+ // Activate the parent window so the plan viewer becomes visible
+ var topLevel = TopLevel.GetTopLevel(this);
+ if (topLevel is Window parentWindow)
+ parentWindow.Activate();
+
+ // Select the node (highlights it and shows properties)
+ SelectNode(targetBorder, targetNode);
+
+ // Ensure zoom level makes the node comfortably visible
+ var viewWidth = PlanScrollViewer.Bounds.Width;
+ var viewHeight = PlanScrollViewer.Bounds.Height;
+ if (viewWidth <= 0 || viewHeight <= 0)
+ return;
+
+ // If the node is too small at the current zoom, zoom in so it's ~1/3 of the viewport
+ var nodeW = PlanLayoutEngine.NodeWidth;
+ var nodeH = PlanLayoutEngine.GetNodeHeight(targetNode);
+ var minVisibleZoom = Math.Min(viewWidth / (nodeW * 4), viewHeight / (nodeH * 4));
+ if (_zoomLevel < minVisibleZoom)
+ SetZoom(Math.Min(minVisibleZoom, 1.0));
+
+ // Scroll to center the node in the viewport
+ var centerX = (targetNode.X + nodeW / 2) * _zoomLevel - viewWidth / 2;
+ var centerY = (targetNode.Y + nodeH / 2) * _zoomLevel - viewHeight / 2;
+ centerX = Math.Max(0, centerX);
+ centerY = Math.Max(0, centerY);
+
+ Avalonia.Threading.Dispatcher.UIThread.Post(() =>
+ {
+ PlanScrollViewer.Offset = new Vector(centerX, centerY);
+ });
+ }
public void LoadPlan(string planXml, string label, string? queryText = null)
{
@@ -430,6 +523,27 @@ private Border CreateNodeVisual(PlanNode node, int totalWarningCount = -1)
iconRow.Children.Add(parBadge);
}
+ // Nonclustered index count badge (modification operators maintaining multiple NC indexes)
+ if (node.NonClusteredIndexCount > 0)
+ {
+ var ncBadge = new Border
+ {
+ Background = new SolidColorBrush(Color.FromRgb(0x6C, 0x75, 0x7D)),
+ CornerRadius = new CornerRadius(4),
+ Padding = new Thickness(4, 1),
+ Margin = new Thickness(4, 0, 0, 0),
+ VerticalAlignment = VerticalAlignment.Center,
+ Child = new TextBlock
+ {
+ Text = $"+{node.NonClusteredIndexCount} NC",
+ FontSize = 10,
+ FontWeight = FontWeight.SemiBold,
+ Foreground = Brushes.White
+ }
+ };
+ iconRow.Children.Add(ncBadge);
+ }
+
stack.Children.Add(iconRow);
// Operator name
@@ -764,6 +878,9 @@ private ContextMenu BuildNodeContextMenu(PlanNode node)
menu.Items.Add(copySeekItem);
}
+ // Schema lookup items (Show Indexes, Show Table Definition)
+ AddSchemaMenuItems(menu, node);
+
return menu;
}
@@ -906,7 +1023,7 @@ private void ShowPropertiesPanel(PlanNode node)
|| node.SortDistinct || node.StartupExpression
|| node.NLOptimized || node.WithOrderedPrefetch || node.WithUnorderedPrefetch
|| node.WithTies || node.Remoting || node.LocalParallelism
- || node.SpoolStack || node.DMLRequestSort
+ || node.SpoolStack || node.DMLRequestSort || node.NonClusteredIndexCount > 0
|| !string.IsNullOrEmpty(node.OffsetExpression) || node.TopRows > 0
|| !string.IsNullOrEmpty(node.ConstantScanValues)
|| !string.IsNullOrEmpty(node.UdxUsedColumns);
@@ -955,6 +1072,12 @@ private void ShowPropertiesPanel(PlanNode node)
AddPropertyRow("Primary Node Id", $"{node.PrimaryNodeId}");
if (node.DMLRequestSort)
AddPropertyRow("DML Request Sort", "True");
+ if (node.NonClusteredIndexCount > 0)
+ {
+ AddPropertyRow("NC Indexes Maintained", $"{node.NonClusteredIndexCount}");
+ foreach (var ixName in node.NonClusteredIndexNames)
+ AddPropertyRow("", ixName, isCode: true);
+ }
if (!string.IsNullOrEmpty(node.ActionColumn))
AddPropertyRow("Action Column", node.ActionColumn, isCode: true);
if (!string.IsNullOrEmpty(node.SegmentColumn))
@@ -1970,6 +2093,10 @@ private object BuildNodeTooltipContent(PlanNode node, List? allWarn
AddTooltipRow(stack, "Scan Direction", node.ScanDirection);
}
+ // NC index maintenance count
+ if (node.NonClusteredIndexCount > 0)
+ AddTooltipRow(stack, "NC Indexes Maintained", string.Join(", ", node.NonClusteredIndexNames));
+
// Operator details (key items only in tooltip)
var hasTooltipDetails = !string.IsNullOrEmpty(node.OrderBy)
|| !string.IsNullOrEmpty(node.TopExpression)
@@ -2865,12 +2992,40 @@ private void SetZoom(double level)
ZoomLevelText.Text = $"{(int)(_zoomLevel * 100)}%";
}
+ ///
+ /// 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);
+ });
+ }
+
private void PlanScrollViewer_PointerWheelChanged(object? sender, PointerWheelEventArgs e)
{
if (e.KeyModifiers.HasFlag(KeyModifiers.Control))
{
e.Handled = true;
- SetZoom(_zoomLevel + (e.Delta.Y > 0 ? ZoomStep : -ZoomStep));
+ var newLevel = _zoomLevel + (e.Delta.Y > 0 ? ZoomStep : -ZoomStep);
+ SetZoomAtPoint(newLevel, e.GetPosition(PlanScrollViewer));
}
}
@@ -3155,6 +3310,15 @@ private async void CopyStatementText_Click(object? sender, RoutedEventArgs e)
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);
@@ -3214,6 +3378,405 @@ private IBrush FindBrushResource(string key)
}
#endregion
+
+ #region Plan Viewer Connection
+
+ private async void PlanConnect_Click(object? sender, RoutedEventArgs e)
+ {
+ if (_planCredentialService == null || _planConnectionStore == null) return;
+
+ var dialog = new ConnectionDialog(_planCredentialService, _planConnectionStore);
+ var topLevel = TopLevel.GetTopLevel(this);
+ if (topLevel is not Window parentWindow) return;
+
+ var result = await dialog.ShowDialog(parentWindow);
+ if (result != true || dialog.ResultConnection == null) return;
+
+ _planConnection = dialog.ResultConnection;
+ _planSelectedDatabase = dialog.ResultDatabase;
+ ConnectionString = _planConnection.GetConnectionString(_planCredentialService, _planSelectedDatabase);
+
+ PlanServerLabel.Text = _planConnection.ServerName;
+ PlanServerLabel.Foreground = Brushes.LimeGreen;
+ PlanConnectButton.Content = "Reconnect";
+
+ // Populate database dropdown
+ try
+ {
+ var connStr = _planConnection.GetConnectionString(_planCredentialService, "master");
+ await using var conn = new SqlConnection(connStr);
+ await conn.OpenAsync();
+
+ var databases = new List();
+ using var cmd = new SqlCommand(
+ "SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; 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));
+
+ PlanDatabaseBox.ItemsSource = databases;
+ PlanDatabaseBox.IsEnabled = true;
+
+ if (_planSelectedDatabase != null)
+ {
+ for (int i = 0; i < PlanDatabaseBox.Items.Count; i++)
+ {
+ if (PlanDatabaseBox.Items[i]?.ToString() == _planSelectedDatabase)
+ {
+ PlanDatabaseBox.SelectedIndex = i;
+ break;
+ }
+ }
+ }
+ }
+ catch
+ {
+ PlanDatabaseBox.IsEnabled = false;
+ }
+ }
+
+ private void PlanDatabase_SelectionChanged(object? sender, SelectionChangedEventArgs e)
+ {
+ if (_planConnection == null || _planCredentialService == null || PlanDatabaseBox.SelectedItem == null) return;
+
+ _planSelectedDatabase = PlanDatabaseBox.SelectedItem.ToString();
+ ConnectionString = _planConnection.GetConnectionString(_planCredentialService, _planSelectedDatabase);
+ }
+
+ #endregion
+
+ #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
}
/// Sort DataGrid column by a long property on StatementRow.
diff --git a/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs b/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs
index b277089..7da0deb 100644
--- a/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs
+++ b/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs
@@ -95,6 +95,13 @@ private void SetupSyntaxHighlighting()
_textMateInstallation.SetGrammar(registryOptions.GetScopeByLanguageId("sql"));
}
+ // Schema context menu items — stored as fields so we can toggle visibility on menu open
+ private MenuItem? _showIndexesItem;
+ private MenuItem? _showTableDefItem;
+ private MenuItem? _showObjectDefItem;
+ private Separator? _schemaSeparator;
+ private ResolvedSqlObject? _contextMenuObject;
+
private void SetupEditorContextMenu()
{
var cutItem = new MenuItem { Header = "Cut" };
@@ -151,10 +158,443 @@ private void SetupEditorContextMenu()
await CaptureAndShowPlan(estimated: false, queryTextOverride: text);
};
- QueryEditor.TextArea.ContextMenu = new ContextMenu
+ // 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, _) =>
{
- Items = { cutItem, copyItem, pasteItem, new Separator(), selectAllItem, new Separator(), executeFromCursorItem, executeCurrentBatchItem }
+ 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)
@@ -634,6 +1074,11 @@ private async Task CaptureAndShowPlan(bool estimated, string? queryTextOverride
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;
@@ -659,12 +1104,17 @@ private async Task CaptureAndShowPlan(bool estimated, string? queryTextOverride
}
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);
+ return (ResultMapper.Map(viewer.CurrentPlan, "query editor", _serverMetadata), viewer);
}
// Fallback: find the most recent plan tab
@@ -673,20 +1123,20 @@ private async Task CaptureAndShowPlan(bool estimated, string? queryTextOverride
if (SubTabControl.Items[i] is TabItem planTab && planTab.Content is PlanViewerControl v
&& v.CurrentPlan != null)
{
- return ResultMapper.Map(v.CurrentPlan, "query editor");
+ return (ResultMapper.Map(v.CurrentPlan, "query editor"), v);
}
}
- return null;
+ return (null, null);
}
private void HumanAdvice_Click(object? sender, RoutedEventArgs e)
{
- var analysis = GetCurrentAnalysis();
+ 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);
+ ShowAdviceWindow("Advice for Humans", text, analysis, viewer);
}
private void RobotAdvice_Click(object? sender, RoutedEventArgs e)
@@ -698,82 +1148,9 @@ private void RobotAdvice_Click(object? sender, RoutedEventArgs e)
ShowAdviceWindow("Advice for Robots", json);
}
- private void ShowAdviceWindow(string title, string content, AnalysisResult? analysis = null)
+ private void ShowAdviceWindow(string title, string content, AnalysisResult? analysis = null, PlanViewerControl? sourceViewer = null)
{
- var styledContent = AdviceContentBuilder.Build(content, analysis);
-
- var scrollViewer = new ScrollViewer
- {
- Content = styledContent,
- HorizontalScrollBarVisibility = Avalonia.Controls.Primitives.ScrollBarVisibility.Disabled,
- VerticalScrollBarVisibility = Avalonia.Controls.Primitives.ScrollBarVisibility.Auto
- };
-
- var copyBtn = new Button
- {
- Content = "Copy to Clipboard",
- Height = 32,
- Padding = new Avalonia.Thickness(16, 0),
- FontSize = 12,
- HorizontalContentAlignment = HorizontalAlignment.Center,
- VerticalContentAlignment = VerticalAlignment.Center,
- Theme = (Avalonia.Styling.ControlTheme)this.FindResource("AppButton")!
- };
-
- var closeBtn = new Button
- {
- Content = "Close",
- 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")!
- };
-
- var buttonPanel = new StackPanel
- {
- Orientation = Avalonia.Layout.Orientation.Horizontal,
- HorizontalAlignment = HorizontalAlignment.Right,
- Margin = new Avalonia.Thickness(0, 8, 0, 0)
- };
- buttonPanel.Children.Add(copyBtn);
- buttonPanel.Children.Add(closeBtn);
-
- var panel = new DockPanel { Margin = new Avalonia.Thickness(12) };
- DockPanel.SetDock(buttonPanel, Dock.Bottom);
- panel.Children.Add(buttonPanel);
- panel.Children.Add(scrollViewer);
-
- var window = new Window
- {
- Title = $"Performance Studio — {title}",
- Width = 700,
- Height = 600,
- MinWidth = 400,
- MinHeight = 300,
- Icon = GetParentWindow().Icon,
- Background = new SolidColorBrush(Color.Parse("#1A1D23")),
- Foreground = new SolidColorBrush(Color.Parse("#E4E6EB")),
- Content = panel
- };
-
- copyBtn.Click += async (_, _) =>
- {
- var clipboard = window.Clipboard;
- if (clipboard != null)
- {
- await clipboard.SetTextAsync(content);
- copyBtn.Content = "Copied!";
- await Task.Delay(1500);
- copyBtn.Content = "Copy to Clipboard";
- }
- };
-
- closeBtn.Click += (_, _) => window.Close();
-
- window.Show(GetParentWindow());
+ AdviceWindowHelper.Show(GetParentWindow(), title, content, analysis, sourceViewer);
}
private void AddPlanTab(string planXml, string queryText, bool estimated, string? labelOverride = null)
@@ -783,6 +1160,11 @@ private void AddPlanTab(string planXml, string queryText, bool estimated, string
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
@@ -1471,6 +1853,11 @@ private async void GetActualPlan_Click(object? sender, RoutedEventArgs e)
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;
}
diff --git a/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml.cs b/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml.cs
index a192af6..08f3ee1 100644
--- a/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml.cs
+++ b/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml.cs
@@ -587,12 +587,19 @@ private void LoadHighlightedPlan_Click(object? sender, RoutedEventArgs e)
private async 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(
_connectionString,
- row.QueryId,
+ row.QueryHash,
row.FullQueryText,
- _database);
+ _database,
+ initialMetricTag: metricTag,
+ slicerStartUtc: _slicerStartUtc,
+ slicerEndUtc: _slicerEndUtc,
+ slicerDaysBack: _slicerDaysBack);
var topLevel = Avalonia.Controls.TopLevel.GetTopLevel(this);
if (topLevel is Window parentWindow)
diff --git a/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml b/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml
index 08e58fb..04d9188 100644
--- a/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml
+++ b/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml
@@ -9,26 +9,43 @@
Icon="avares://PlanViewer.App/EDD.ico"
Background="{DynamicResource BackgroundBrush}">
-
+
-
-
-
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
@@ -42,68 +59,99 @@
+
+
+
+
+
-
-
-
-
-
-
-
-
-
+
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
-
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
diff --git a/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml.cs b/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml.cs
index f3b51af..b71aef8 100644
--- a/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml.cs
+++ b/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml.cs
@@ -7,7 +7,9 @@
using Avalonia.Controls.Primitives;
using Avalonia.Input;
using Avalonia.Interactivity;
+using Avalonia.Layout;
using Avalonia.Media;
+using Avalonia.VisualTree;
using PlanViewer.Core.Models;
using PlanViewer.Core.Services;
using ScottPlot;
@@ -17,16 +19,46 @@ namespace PlanViewer.App.Dialogs;
public partial class QueryStoreHistoryWindow : Window
{
private readonly string _connectionString;
- private readonly long _queryId;
+ private readonly string _queryHash;
private readonly string _database;
+ private readonly string _queryText;
+ private readonly DateTime? _slicerStartUtc;
+ private readonly DateTime? _slicerEndUtc;
+ private readonly int _maxHoursBack;
+ private bool _useFullHistory;
private CancellationTokenSource? _fetchCts;
private List _historyData = new();
- private readonly List<(ScottPlot.Plottables.Scatter Scatter, string Label)> _scatters = new();
+ private readonly List<(ScottPlot.Plottables.Scatter Scatter, string Label, string PlanHash)> _scatters = new();
// Hover tooltip
private readonly Popup _tooltip;
private readonly TextBlock _tooltipText;
+ // Box selection state
+ private bool _isDragging;
+ private Point _dragStartPoint;
+ private ScottPlot.Plottables.Rectangle? _selectionRect;
+ private readonly HashSet _selectedRowIndices = new();
+
+ // Highlight markers for selected dots
+ private readonly List _highlightMarkers = new();
+
+ // Color mapping: plan hash -> color
+ private readonly Dictionary _planHashColorMap = new();
+
+ // Legend state
+ private bool _legendExpanded;
+ private bool _suppressGridSelectionEvent;
+
+ // Legend highlight: which plan hash is currently highlighted (null = none)
+ private string? _highlightedPlanHash;
+ private ScottPlot.Plottables.HorizontalLine? _avgLine;
+
+ // Active button highlight brush
+ private static readonly SolidColorBrush ActiveButtonBg = new(Avalonia.Media.Color.FromRgb(0x4F, 0xC3, 0xF7));
+ private static readonly SolidColorBrush ActiveButtonFg = new(Avalonia.Media.Color.FromRgb(0x11, 0x12, 0x17));
+ private static readonly SolidColorBrush InactiveButtonFg = new(Avalonia.Media.Color.FromRgb(0x9D, 0xA5, 0xB4));
+
private static readonly ScottPlot.Color[] PlanColors =
{
ScottPlot.Color.FromHex("#4FC3F7"),
@@ -39,21 +71,56 @@ public partial class QueryStoreHistoryWindow : Window
ScottPlot.Color.FromHex("#A1887F"),
};
- public QueryStoreHistoryWindow(string connectionString, long queryId,
- string queryText, string database, int hoursBack = 24)
+ // Map grid orderBy tags to history metric tags
+ private static readonly Dictionary OrderByToMetricTag = new()
+ {
+ ["cpu"] = "TotalCpuMs",
+ ["avg-cpu"] = "AvgCpuMs",
+ ["duration"] = "TotalDurationMs",
+ ["avg-duration"] = "AvgDurationMs",
+ ["reads"] = "TotalLogicalReads",
+ ["avg-reads"] = "AvgLogicalReads",
+ ["writes"] = "TotalLogicalWrites",
+ ["avg-writes"] = "AvgLogicalWrites",
+ ["physical-reads"] = "TotalPhysicalReads",
+ ["avg-physical-reads"] = "AvgPhysicalReads",
+ ["memory"] = "TotalMemoryMb",
+ ["avg-memory"] = "AvgMemoryMb",
+ ["executions"] = "CountExecutions",
+ };
+
+ public QueryStoreHistoryWindow(string connectionString, string queryHash,
+ string queryText, string database,
+ string initialMetricTag = "AvgCpuMs",
+ DateTime? slicerStartUtc = null, DateTime? slicerEndUtc = null,
+ int slicerDaysBack = 30)
{
_connectionString = connectionString;
- _queryId = queryId;
+ _queryHash = queryHash;
_database = database;
+ _queryText = queryText;
+ _slicerStartUtc = slicerStartUtc;
+ _slicerEndUtc = slicerEndUtc;
+ _maxHoursBack = slicerDaysBack * 24;
InitializeComponent();
- HoursBackBox.Value = hoursBack;
+ QueryIdentifierText.Text = $"Query Store History: {queryHash} in [{database}]";
+ QueryTextBox.Text = queryText;
+
+ // Select initial metric in the combo box
+ var metricTag = initialMetricTag;
+ foreach (ComboBoxItem item in MetricSelector.Items)
+ {
+ if (item.Tag?.ToString() == metricTag)
+ {
+ MetricSelector.SelectedItem = item;
+ break;
+ }
+ }
- var preview = queryText.Length > 120
- ? queryText[..120].Replace("\n", " ").Replace("\r", "") + "..."
- : queryText.Replace("\n", " ").Replace("\r", "");
- QueryIdentifierText.Text = $"Query Store History: Query {queryId} in [{database}]";
- SummaryText.Text = preview;
+ // Default to range period mode when slicer range is available
+ _useFullHistory = !(_slicerStartUtc.HasValue && _slicerEndUtc.HasValue);
+ UpdateRangeButtons();
// Build hover tooltip
_tooltipText = new TextBlock
@@ -81,10 +148,25 @@ public QueryStoreHistoryWindow(string connectionString, long queryId,
HistoryChart.PointerMoved += OnChartPointerMoved;
HistoryChart.PointerExited += (_, _) => _tooltip.IsOpen = false;
+ HistoryChart.PointerPressed += OnChartPointerPressed;
+ HistoryChart.PointerReleased += OnChartPointerReleased;
+
+ // Disable ScottPlot's built-in left-click-drag pan so our box selection works
+ HistoryChart.UserInputProcessor.LeftClickDragPan(enable: false);
Opened += async (_, _) => await LoadHistoryAsync();
}
+ ///
+ /// Maps a grid orderBy tag (e.g. "cpu", "avg-duration") to the history metric tag.
+ ///
+ public static string MapOrderByToMetricTag(string orderBy)
+ {
+ return OrderByToMetricTag.TryGetValue(orderBy?.ToLowerInvariant() ?? "", out var tag)
+ ? tag
+ : "AvgCpuMs";
+ }
+
private async System.Threading.Tasks.Task LoadHistoryAsync()
{
_fetchCts?.Cancel();
@@ -92,20 +174,34 @@ private async System.Threading.Tasks.Task LoadHistoryAsync()
_fetchCts = new CancellationTokenSource();
var ct = _fetchCts.Token;
- var hoursBack = (int)(HoursBackBox.Value ?? 24);
- RefreshButton.IsEnabled = false;
StatusText.Text = "Loading...";
try
{
- _historyData = await QueryStoreService.FetchHistoryAsync(
- _connectionString, _queryId, hoursBack, ct);
+ if (_useFullHistory)
+ {
+ _historyData = await QueryStoreService.FetchAggregateHistoryAsync(
+ _connectionString, _queryHash, _maxHoursBack, ct);
+ }
+ else if (_slicerStartUtc.HasValue && _slicerEndUtc.HasValue)
+ {
+ _historyData = await QueryStoreService.FetchAggregateHistoryAsync(
+ _connectionString, _queryHash, ct: ct,
+ startUtc: _slicerStartUtc.Value, endUtc: _slicerEndUtc.Value);
+ }
+ else
+ {
+ _historyData = await QueryStoreService.FetchAggregateHistoryAsync(
+ _connectionString, _queryHash, _maxHoursBack, ct);
+ }
+ BuildColorMap();
HistoryDataGrid.ItemsSource = _historyData;
+ ApplyColorIndicators();
if (_historyData.Count > 0)
{
- var planCount = _historyData.Select(r => r.PlanId).Distinct().Count();
+ var planCount = _historyData.Select(r => r.QueryPlanHash).Distinct().Count();
var totalExec = _historyData.Sum(r => r.CountExecutions);
var first = TimeDisplayHelper.ConvertForDisplay(_historyData.Min(r => r.IntervalStartUtc));
var last = TimeDisplayHelper.ConvertForDisplay(_historyData.Max(r => r.IntervalStartUtc));
@@ -119,6 +215,7 @@ private async System.Threading.Tasks.Task LoadHistoryAsync()
}
UpdateChart();
+ PopulateLegendPanel();
}
catch (OperationCanceledException)
{
@@ -128,16 +225,212 @@ private async System.Threading.Tasks.Task LoadHistoryAsync()
{
StatusText.Text = ex.Message.Length > 80 ? ex.Message[..80] + "..." : ex.Message;
}
- finally
+ }
+
+ private void BuildColorMap()
+ {
+ _planHashColorMap.Clear();
+ var hashes = _historyData.Select(r => r.QueryPlanHash).Distinct().OrderBy(h => h).ToList();
+ for (int i = 0; i < hashes.Count; i++)
+ _planHashColorMap[hashes[i]] = PlanColors[i % PlanColors.Length];
+ }
+
+ private void ApplyColorIndicators()
+ {
+ HistoryDataGrid.LoadingRow -= OnDataGridLoadingRow;
+ HistoryDataGrid.LoadingRow += OnDataGridLoadingRow;
+ }
+
+ private void OnDataGridLoadingRow(object? sender, DataGridRowEventArgs e)
+ {
+ if (e.Row.DataContext is QueryStoreHistoryRow row &&
+ _planHashColorMap.TryGetValue(row.QueryPlanHash, out var color))
{
- RefreshButton.IsEnabled = true;
+ var avColor = Avalonia.Media.Color.FromRgb(color.R, color.G, color.B);
+ var brush = new SolidColorBrush(avColor);
+ e.Row.Tag = brush;
+
+ // Try to apply immediately (works for recycled rows whose visual tree already exists)
+ if (TryApplyColorIndicator(e.Row, brush))
+ return;
+ }
+
+ // Visual tree not ready yet (first load) — defer to Loaded
+ e.Row.Loaded -= OnRowLoaded;
+ e.Row.Loaded += OnRowLoaded;
+ }
+
+ private void OnRowLoaded(object? sender, RoutedEventArgs e)
+ {
+ if (sender is not DataGridRow dgRow) return;
+ dgRow.Loaded -= OnRowLoaded;
+
+ if (dgRow.Tag is SolidColorBrush brush)
+ TryApplyColorIndicator(dgRow, brush);
+ }
+
+ private bool TryApplyColorIndicator(DataGridRow dgRow, SolidColorBrush brush)
+ {
+ var presenter = FindVisualChild(dgRow);
+ if (presenter == null) return false;
+
+ var cell = presenter.Children.OfType().FirstOrDefault();
+ if (cell == null) return false;
+
+ var border = FindVisualChild(cell, "ColorIndicator");
+ if (border == null) return false;
+
+ border.Background = brush;
+ return true;
+ }
+
+ private static T? FindVisualChild(Avalonia.Visual parent, string? name = null) where T : Avalonia.Visual
+ {
+ if (parent is T t && (name == null || (t is Control c && c.Name == name)))
+ return t;
+
+ var children = parent.GetVisualChildren();
+ foreach (var child in children)
+ {
+ if (child is Avalonia.Visual vc)
+ {
+ var found = FindVisualChild(vc, name);
+ if (found != null) return found;
+ }
+ }
+ return null;
+ }
+
+ // ── Legend ────────────────────────────────────────────────────────────
+
+ private void PopulateLegendPanel()
+ {
+ LegendItemsPanel.Children.Clear();
+ foreach (var (hash, color) in _planHashColorMap.OrderBy(kv => kv.Key))
+ {
+ var avColor = Avalonia.Media.Color.FromRgb(color.R, color.G, color.B);
+ var item = new StackPanel
+ {
+ Orientation = Avalonia.Layout.Orientation.Horizontal,
+ Spacing = 6,
+ Tag = hash,
+ Cursor = new Avalonia.Input.Cursor(Avalonia.Input.StandardCursorType.Hand)
+ };
+ item.Children.Add(new Border
+ {
+ Width = 12, Height = 12,
+ CornerRadius = new CornerRadius(2),
+ Background = new SolidColorBrush(avColor),
+ VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center
+ });
+ item.Children.Add(new TextBlock
+ {
+ Text = hash,
+ FontSize = 11,
+ VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center,
+ Foreground = new SolidColorBrush(Avalonia.Media.Color.FromRgb(0xE0, 0xE0, 0xE0))
+ });
+ item.PointerPressed += OnLegendItemClicked;
+ LegendItemsPanel.Children.Add(item);
}
}
+ private void OnLegendItemClicked(object? sender, PointerPressedEventArgs e)
+ {
+ if (sender is not StackPanel panel || panel.Tag is not string planHash) return;
+
+ // Toggle: click again to deselect
+ if (_highlightedPlanHash == planHash)
+ _highlightedPlanHash = null;
+ else
+ _highlightedPlanHash = planHash;
+
+ ApplyPlanHighlight();
+ UpdateLegendVisuals();
+ }
+
+ private void UpdateLegendVisuals()
+ {
+ foreach (var child in LegendItemsPanel.Children)
+ {
+ if (child is not StackPanel panel || panel.Tag is not string hash) continue;
+ var isActive = _highlightedPlanHash == null || _highlightedPlanHash == hash;
+ panel.Opacity = isActive ? 1.0 : 0.4;
+ }
+ }
+
+ private void ApplyPlanHighlight()
+ {
+ var tag = (MetricSelector.SelectedItem as ComboBoxItem)?.Tag?.ToString() ?? "AvgCpuMs";
+
+ foreach (var (scatter, _, planHash) in _scatters)
+ {
+ if (_highlightedPlanHash == null)
+ {
+ // No highlight: restore normal appearance
+ var color = _planHashColorMap.GetValueOrDefault(planHash, PlanColors[0]);
+ scatter.Color = color.WithAlpha(140);
+ scatter.LineWidth = 2;
+ scatter.MarkerSize = 8;
+ }
+ else if (planHash == _highlightedPlanHash)
+ {
+ // Highlighted plan: emphasized
+ var color = _planHashColorMap.GetValueOrDefault(planHash, PlanColors[0]);
+ scatter.Color = color.WithAlpha(220);
+ scatter.LineWidth = 4;
+ scatter.MarkerSize = 10;
+ }
+ else
+ {
+ // Other plans: dimmed
+ var color = _planHashColorMap.GetValueOrDefault(planHash, PlanColors[0]);
+ scatter.Color = color.WithAlpha(40);
+ scatter.LineWidth = 1;
+ scatter.MarkerSize = 5;
+ }
+ }
+
+ // Recompute average line based on highlighted plan or all data
+ if (_avgLine != null)
+ {
+ var relevantRows = _highlightedPlanHash != null
+ ? _historyData.Where(r => r.QueryPlanHash == _highlightedPlanHash).ToList()
+ : _historyData;
+
+ if (relevantRows.Count > 0)
+ {
+ var avg = relevantRows.Select(r => GetMetricValue(r, tag)).Average();
+ _avgLine.Y = avg;
+ _avgLine.Text = $"avg: {avg:N0}";
+ _avgLine.IsVisible = true;
+ }
+ else
+ {
+ _avgLine.IsVisible = false;
+ }
+ }
+
+ HistoryChart.Refresh();
+ }
+
+ private void LegendToggle_Click(object? sender, RoutedEventArgs e)
+ {
+ _legendExpanded = !_legendExpanded;
+ LegendPanel.IsVisible = _legendExpanded;
+ LegendArrow.Text = _legendExpanded ? "\u25b2" : "\u25bc";
+ }
+
+ // ── Chart ────────────────────────────────────────────────────────────
+
private void UpdateChart()
{
HistoryChart.Plot.Clear();
_scatters.Clear();
+ _selectionRect = null;
+ _highlightMarkers.Clear();
+ _avgLine = null;
+ _highlightedPlanHash = null;
if (_historyData.Count == 0)
{
@@ -150,37 +443,375 @@ private void UpdateChart()
var label = selected?.Content?.ToString() ?? "Avg CPU (ms)";
var planGroups = _historyData
- .GroupBy(r => r.PlanId)
+ .GroupBy(r => r.QueryPlanHash)
.OrderBy(g => g.Key)
.ToList();
- int colorIndex = 0;
foreach (var group in planGroups)
{
+ var planHash = group.Key;
+ var color = _planHashColorMap.GetValueOrDefault(planHash, PlanColors[0]);
+
var ordered = group.OrderBy(r => r.IntervalStartUtc).ToList();
var xs = ordered.Select(r => TimeDisplayHelper.ConvertForDisplay(r.IntervalStartUtc).ToOADate()).ToArray();
var ys = ordered.Select(r => GetMetricValue(r, tag)).ToArray();
var scatter = HistoryChart.Plot.Add.Scatter(xs, ys);
- scatter.Color = PlanColors[colorIndex % PlanColors.Length];
- scatter.LegendText = $"Plan {group.Key}";
+ scatter.Color = color.WithAlpha(140);
+ scatter.LegendText = "";
scatter.LineWidth = 2;
- scatter.MarkerSize = ordered.Count <= 2 ? 8 : 4;
+ scatter.MarkerSize = 8;
+ scatter.MarkerShape = MarkerShape.FilledCircle;
+ scatter.MarkerLineColor = ScottPlot.Color.FromHex("#AAAAAA");
+ scatter.MarkerLineWidth = 1f;
- _scatters.Add((scatter, $"Plan {group.Key}"));
- colorIndex++;
+ _scatters.Add((scatter, planHash.Length > 10 ? planHash[..10] : planHash, planHash));
}
- HistoryChart.Plot.Axes.DateTimeTicksBottom();
+ // Add average line with label positioned just above the line
+ var allValues = _historyData.Select(r => GetMetricValue(r, tag)).ToArray();
+ if (allValues.Length > 0)
+ {
+ var avg = allValues.Average();
+ _avgLine = HistoryChart.Plot.Add.HorizontalLine(avg);
+ _avgLine.Color = ScottPlot.Color.FromHex("#FFD54F").WithAlpha(150);
+ _avgLine.LineWidth = 2f;
+ _avgLine.LinePattern = LinePattern.DenselyDashed;
+ _avgLine.Text = $"avg: {avg:N0}";
+ _avgLine.LabelFontColor = ScottPlot.Color.FromHex("#9DA5B4");
+ _avgLine.LabelFontSize = 11;
+ _avgLine.LabelBackgroundColor = ScottPlot.Color.FromHex("#333333").WithAlpha(170);
+ _avgLine.LabelOppositeAxis = false;
+ _avgLine.LabelRotation = 0;
+ _avgLine.LabelAlignment = Alignment.LowerLeft;
+ _avgLine.LabelOffsetX = 38;
+ _avgLine.LabelOffsetY = -8;
+ }
+
+ // Y-axis always includes 0 as origin
+ HistoryChart.Plot.Axes.AutoScale();
+ var yLimits = HistoryChart.Plot.Axes.GetLimits();
+ HistoryChart.Plot.Axes.SetLimitsY(0, yLimits.Top * 1.1);
+
+ // Disable ScottPlot's built-in legend — we use our custom overlay
+ HistoryChart.Plot.HideLegend();
+
+ // Smart X-axis labels
+ ConfigureSmartXAxis();
+
HistoryChart.Plot.YLabel(label);
ApplyDarkTheme();
HistoryChart.Refresh();
}
+ private void ConfigureSmartXAxis()
+ {
+ if (_historyData.Count == 0) return;
+
+ var minTime = _historyData.Min(r => r.IntervalStartUtc);
+ var maxTime = _historyData.Max(r => r.IntervalStartUtc);
+ var span = maxTime - minTime;
+
+ HistoryChart.Plot.Axes.DateTimeTicksBottom();
+
+ if (span.TotalHours <= 48)
+ {
+ HistoryChart.Plot.Axes.Bottom.TickLabelStyle.ForeColor = ScottPlot.Color.FromHex("#9DA5B4");
+ HistoryChart.Plot.Axes.Bottom.TickGenerator = new ScottPlot.TickGenerators.DateTimeAutomatic
+ {
+ LabelFormatter = dt => dt.ToString("HH:mm\nMM/dd")
+ };
+ }
+ else if (span.TotalDays <= 14)
+ {
+ HistoryChart.Plot.Axes.Bottom.TickGenerator = new ScottPlot.TickGenerators.DateTimeAutomatic
+ {
+ LabelFormatter = dt => dt.ToString("HH:mm\nMM/dd")
+ };
+ }
+ else
+ {
+ HistoryChart.Plot.Axes.Bottom.TickGenerator = new ScottPlot.TickGenerators.DateTimeAutomatic
+ {
+ LabelFormatter = dt => dt.ToString("MM/dd\nyyyy")
+ };
+ }
+ }
+
+ // ── Dot highlighting on chart ────────────────────────────────────────
+
+ private void ClearHighlightMarkers()
+ {
+ foreach (var m in _highlightMarkers)
+ HistoryChart.Plot.Remove(m);
+ _highlightMarkers.Clear();
+ }
+
+ private void HighlightDotsOnChart(HashSet rowIndices)
+ {
+ ClearHighlightMarkers();
+ if (rowIndices.Count == 0)
+ {
+ HistoryChart.Refresh();
+ return;
+ }
+
+ var tag = (MetricSelector.SelectedItem as ComboBoxItem)?.Tag?.ToString() ?? "AvgCpuMs";
+
+ // Group selected rows by plan hash for coloring
+ var groups = rowIndices
+ .Where(i => i >= 0 && i < _historyData.Count)
+ .Select(i => _historyData[i])
+ .GroupBy(r => r.QueryPlanHash);
+
+ foreach (var group in groups)
+ {
+ var color = _planHashColorMap.GetValueOrDefault(group.Key, PlanColors[0]);
+ var xs = group.Select(r => TimeDisplayHelper.ConvertForDisplay(r.IntervalStartUtc).ToOADate()).ToArray();
+ var ys = group.Select(r => GetMetricValue(r, tag)).ToArray();
+
+ // Bigger filled dot with white border for emphasis
+ var highlight = HistoryChart.Plot.Add.Scatter(xs, ys);
+ highlight.LineWidth = 0;
+ highlight.MarkerSize = 14;
+ highlight.MarkerShape = MarkerShape.FilledCircle;
+ highlight.Color = color;
+ highlight.MarkerLineColor = ScottPlot.Colors.White;
+ highlight.MarkerLineWidth = 2.5f;
+
+ _highlightMarkers.Add(highlight);
+ }
+
+ HistoryChart.Refresh();
+ }
+
+ // ── Box selection ────────────────────────────────────────────────────
+
+ private void OnChartPointerPressed(object? sender, PointerPressedEventArgs e)
+ {
+ if (!e.GetCurrentPoint(HistoryChart).Properties.IsLeftButtonPressed) return;
+
+ _isDragging = true;
+ _dragStartPoint = e.GetPosition(HistoryChart);
+
+ // Remove old selection rect
+ if (_selectionRect != null)
+ {
+ HistoryChart.Plot.Remove(_selectionRect);
+ _selectionRect = null;
+ }
+
+ e.Handled = true;
+ }
+
+ private void OnChartPointerReleased(object? sender, PointerReleasedEventArgs e)
+ {
+ if (!_isDragging) return;
+ _isDragging = false;
+
+ // Remove the drag preview rect
+ if (_selectionRect != null)
+ {
+ HistoryChart.Plot.Remove(_selectionRect);
+ _selectionRect = null;
+ }
+
+ var endPoint = e.GetPosition(HistoryChart);
+ var startCoords = PixelToCoordinates(_dragStartPoint);
+ var endCoords = PixelToCoordinates(endPoint);
+
+ // Determine if this was a click (small drag) or a box selection
+ var dx = Math.Abs(endPoint.X - _dragStartPoint.X);
+ var dy = Math.Abs(endPoint.Y - _dragStartPoint.Y);
+
+ if (dx < 5 && dy < 5)
+ {
+ HandleSingleClickSelection(endPoint);
+ }
+ else
+ {
+ HandleBoxSelection(startCoords, endCoords);
+ }
+
+ e.Handled = true;
+ }
+
+ private ScottPlot.Coordinates PixelToCoordinates(Point pos)
+ {
+ var scaling = HistoryChart.Bounds.Width > 0
+ ? (float)(HistoryChart.Plot.RenderManager.LastRender.FigureRect.Width / HistoryChart.Bounds.Width)
+ : 1f;
+ var pixel = new ScottPlot.Pixel((float)(pos.X * scaling), (float)(pos.Y * scaling));
+ return HistoryChart.Plot.GetCoordinates(pixel);
+ }
+
+ private void HandleSingleClickSelection(Point clickPoint)
+ {
+ if (_scatters.Count == 0) return;
+
+ var scaling = HistoryChart.Bounds.Width > 0
+ ? (float)(HistoryChart.Plot.RenderManager.LastRender.FigureRect.Width / HistoryChart.Bounds.Width)
+ : 1f;
+ var pixel = new ScottPlot.Pixel((float)(clickPoint.X * scaling), (float)(clickPoint.Y * scaling));
+ var mouseCoords = HistoryChart.Plot.GetCoordinates(pixel);
+
+ double bestDist = double.MaxValue;
+ ScottPlot.DataPoint bestPoint = default;
+ string bestPlanHash = "";
+ bool found = false;
+
+ foreach (var (scatter, _, planHash) in _scatters)
+ {
+ var nearest = scatter.Data.GetNearest(mouseCoords, HistoryChart.Plot.LastRender);
+ if (!nearest.IsReal) continue;
+
+ var nearestPixel = HistoryChart.Plot.GetPixel(
+ new ScottPlot.Coordinates(nearest.X, nearest.Y));
+ var d = Math.Sqrt(Math.Pow(nearestPixel.X - pixel.X, 2) + Math.Pow(nearestPixel.Y - pixel.Y, 2));
+
+ if (d < 30 && d < bestDist)
+ {
+ bestDist = d;
+ bestPoint = nearest;
+ bestPlanHash = planHash;
+ found = true;
+ }
+ }
+
+ _selectedRowIndices.Clear();
+
+ if (found)
+ {
+ var clickedTime = DateTime.FromOADate(bestPoint.X);
+ for (int i = 0; i < _historyData.Count; i++)
+ {
+ var row = _historyData[i];
+ var displayTime = TimeDisplayHelper.ConvertForDisplay(row.IntervalStartUtc);
+ if (row.QueryPlanHash == bestPlanHash &&
+ Math.Abs((displayTime - clickedTime).TotalMinutes) < 1)
+ {
+ _selectedRowIndices.Add(i);
+ }
+ }
+ }
+
+ HighlightDotsOnChart(_selectedRowIndices);
+ HighlightGridRows();
+ }
+
+ private void HandleBoxSelection(ScottPlot.Coordinates start, ScottPlot.Coordinates end)
+ {
+ var x1 = Math.Min(start.X, end.X);
+ var x2 = Math.Max(start.X, end.X);
+ var y1 = Math.Min(start.Y, end.Y);
+ var y2 = Math.Max(start.Y, end.Y);
+
+ // Find all data points inside the box
+ var tag = (MetricSelector.SelectedItem as ComboBoxItem)?.Tag?.ToString() ?? "AvgCpuMs";
+ _selectedRowIndices.Clear();
+
+ for (int i = 0; i < _historyData.Count; i++)
+ {
+ var row = _historyData[i];
+ var xVal = TimeDisplayHelper.ConvertForDisplay(row.IntervalStartUtc).ToOADate();
+ var yVal = GetMetricValue(row, tag);
+
+ if (xVal >= x1 && xVal <= x2 && yVal >= y1 && yVal <= y2)
+ _selectedRowIndices.Add(i);
+ }
+
+ HighlightDotsOnChart(_selectedRowIndices);
+ HighlightGridRows();
+ }
+
+ private void HighlightGridRows()
+ {
+ // Scroll to first selected row if any
+ if (_selectedRowIndices.Count > 0)
+ {
+ var firstIdx = _selectedRowIndices.Min();
+ if (firstIdx < _historyData.Count)
+ HistoryDataGrid.ScrollIntoView(_historyData[firstIdx], null);
+ }
+
+ HistoryDataGrid.LoadingRow -= OnHighlightLoadingRow;
+ HistoryDataGrid.LoadingRow += OnHighlightLoadingRow;
+
+ // Force grid to re-render rows
+ _suppressGridSelectionEvent = true;
+ var source = _historyData;
+ HistoryDataGrid.ItemsSource = null;
+ HistoryDataGrid.ItemsSource = source;
+ _suppressGridSelectionEvent = false;
+ }
+
+ private void OnHighlightLoadingRow(object? sender, DataGridRowEventArgs e)
+ {
+ var idx = e.Row.GetIndex();
+ if (_selectedRowIndices.Contains(idx))
+ {
+ e.Row.Background = new SolidColorBrush(Avalonia.Media.Color.FromArgb(60, 79, 195, 247));
+ }
+ else
+ {
+ e.Row.Background = Brushes.Transparent;
+ }
+ }
+
+ // ── Grid row click → chart highlight ─────────────────────────────────
+
+ private void HistoryDataGrid_SelectionChanged(object? sender, SelectionChangedEventArgs e)
+ {
+ if (_suppressGridSelectionEvent) return;
+
+ _selectedRowIndices.Clear();
+ if (HistoryDataGrid.SelectedItems != null)
+ {
+ foreach (var item in HistoryDataGrid.SelectedItems)
+ {
+ if (item is QueryStoreHistoryRow row)
+ {
+ var idx = _historyData.IndexOf(row);
+ if (idx >= 0)
+ _selectedRowIndices.Add(idx);
+ }
+ }
+ }
+
+ HighlightDotsOnChart(_selectedRowIndices);
+ }
+
+ // ── Hover tooltip ────────────────────────────────────────────────────
+
private void OnChartPointerMoved(object? sender, PointerEventArgs e)
{
if (_scatters.Count == 0) { _tooltip.IsOpen = false; return; }
+ // If dragging, update selection rectangle preview
+ if (_isDragging)
+ {
+ var currentPoint = e.GetPosition(HistoryChart);
+ var startCoords = PixelToCoordinates(_dragStartPoint);
+ var currentCoords = PixelToCoordinates(currentPoint);
+
+ if (_selectionRect != null)
+ HistoryChart.Plot.Remove(_selectionRect);
+
+ var x1 = Math.Min(startCoords.X, currentCoords.X);
+ var x2 = Math.Max(startCoords.X, currentCoords.X);
+ var y1 = Math.Min(startCoords.Y, currentCoords.Y);
+ var y2 = Math.Max(startCoords.Y, currentCoords.Y);
+
+ _selectionRect = HistoryChart.Plot.Add.Rectangle(x1, x2, y1, y2);
+ _selectionRect.FillColor = ScottPlot.Color.FromHex("#4FC3F7").WithAlpha(30);
+ _selectionRect.LineColor = ScottPlot.Color.FromHex("#4FC3F7").WithAlpha(120);
+ _selectionRect.LineWidth = 1;
+ HistoryChart.Refresh();
+
+ _tooltip.IsOpen = false;
+ return;
+ }
+
try
{
var pos = e.GetPosition(HistoryChart);
@@ -195,21 +826,21 @@ private void OnChartPointerMoved(object? sender, PointerEventArgs e)
string bestLabel = "";
bool found = false;
- foreach (var (scatter, label) in _scatters)
+ foreach (var (scatter, chartLabel, _) in _scatters)
{
var nearest = scatter.Data.GetNearest(mouseCoords, HistoryChart.Plot.LastRender);
if (!nearest.IsReal) continue;
var nearestPixel = HistoryChart.Plot.GetPixel(
new ScottPlot.Coordinates(nearest.X, nearest.Y));
- double dx = Math.Abs(nearestPixel.X - pixel.X);
- double dy = Math.Abs(nearestPixel.Y - pixel.Y);
+ double ddx = Math.Abs(nearestPixel.X - pixel.X);
+ double ddy = Math.Abs(nearestPixel.Y - pixel.Y);
- if (dx < 80 && dy < bestDist)
+ if (ddx < 80 && ddy < bestDist)
{
- bestDist = dy;
+ bestDist = ddy;
bestPoint = nearest;
- bestLabel = label;
+ bestLabel = chartLabel;
found = true;
}
}
@@ -246,7 +877,8 @@ private void OnChartPointerMoved(object? sender, PointerEventArgs e)
"TotalLogicalReads" => row.TotalLogicalReads,
"TotalLogicalWrites" => row.TotalLogicalWrites,
"TotalPhysicalReads" => row.TotalPhysicalReads,
- "CountExecutions" => row.CountExecutions,
+ "TotalMemoryMb" => row.TotalMemoryMb,
+ "CountExecutions" => row.CountExecutions,
_ => row.AvgCpuMs,
};
@@ -263,9 +895,40 @@ private void ApplyDarkTheme()
HistoryChart.Plot.Grid.MajorLineColor = grid;
HistoryChart.Plot.Axes.Bottom.TickLabelStyle.ForeColor = text;
HistoryChart.Plot.Axes.Left.TickLabelStyle.ForeColor = text;
- HistoryChart.Plot.Legend.BackgroundColor = fig;
- HistoryChart.Plot.Legend.FontColor = ScottPlot.Color.FromHex("#E4E6EB");
- HistoryChart.Plot.Legend.OutlineColor = ScottPlot.Color.FromHex("#3A3D45");
+ }
+
+ private void UpdateRangeButtons()
+ {
+ if (_useFullHistory)
+ {
+ FullHistoryButton.Background = ActiveButtonBg;
+ FullHistoryButton.Foreground = ActiveButtonFg;
+ RangePeriodButton.Background = Brushes.Transparent;
+ RangePeriodButton.Foreground = InactiveButtonFg;
+ }
+ else
+ {
+ RangePeriodButton.Background = ActiveButtonBg;
+ RangePeriodButton.Foreground = ActiveButtonFg;
+ FullHistoryButton.Background = Brushes.Transparent;
+ FullHistoryButton.Foreground = InactiveButtonFg;
+ }
+ }
+
+ private async void RangePeriod_Click(object? sender, RoutedEventArgs e)
+ {
+ if (!_useFullHistory) return;
+ _useFullHistory = false;
+ UpdateRangeButtons();
+ await LoadHistoryAsync();
+ }
+
+ private async void FullHistory_Click(object? sender, RoutedEventArgs e)
+ {
+ if (_useFullHistory) return;
+ _useFullHistory = true;
+ UpdateRangeButtons();
+ await LoadHistoryAsync();
}
private void MetricSelector_SelectionChanged(object? sender, SelectionChangedEventArgs e)
@@ -274,9 +937,12 @@ private void MetricSelector_SelectionChanged(object? sender, SelectionChangedEve
UpdateChart();
}
- private async void Refresh_Click(object? sender, RoutedEventArgs e)
+ private async void CopyQuery_Click(object? sender, RoutedEventArgs e)
{
- await LoadHistoryAsync();
+ if (string.IsNullOrEmpty(_queryText)) return;
+ var clipboard = TopLevel.GetTopLevel(this)?.Clipboard;
+ if (clipboard != null)
+ await clipboard.SetTextAsync(_queryText);
}
private void Close_Click(object? sender, RoutedEventArgs e) => Close();
diff --git a/src/PlanViewer.App/MainWindow.axaml.cs b/src/PlanViewer.App/MainWindow.axaml.cs
index 81706bf..457c9d5 100644
--- a/src/PlanViewer.App/MainWindow.axaml.cs
+++ b/src/PlanViewer.App/MainWindow.axaml.cs
@@ -361,6 +361,7 @@ private void LoadPlanFile(string filePath)
return;
var viewer = new PlanViewerControl();
+ viewer.SetConnectionServices(_credentialService, _connectionStore);
viewer.LoadPlan(xml, fileName);
viewer.SourceFilePath = filePath;
@@ -399,6 +400,7 @@ private async Task PasteXmlAsync()
return;
var viewer = new PlanViewerControl();
+ viewer.SetConnectionServices(_credentialService, _connectionStore);
viewer.LoadPlan(xml, "Pasted Plan");
var content = CreatePlanTabContent(viewer);
@@ -458,7 +460,7 @@ private DockPanel CreatePlanTabContent(PlanViewerControl viewer)
{
if (viewer.CurrentPlan == null) return;
var analysis = ResultMapper.Map(viewer.CurrentPlan, "file", viewer.Metadata);
- ShowAdviceWindow("Advice for Humans", TextFormatter.Format(analysis), analysis);
+ ShowAdviceWindow("Advice for Humans", TextFormatter.Format(analysis), analysis, viewer);
};
Action showRobotAdvice = () =>
@@ -530,6 +532,16 @@ private DockPanel CreatePlanTabContent(PlanViewerControl viewer)
viewer.HumanAdviceRequested += (_, _) => showHumanAdvice();
viewer.RobotAdviceRequested += (_, _) => showRobotAdvice();
viewer.CopyReproRequested += async (_, _) => await copyRepro();
+ viewer.OpenInEditorRequested += (_, queryText) =>
+ {
+ _queryCounter++;
+ var session = new QuerySessionControl(_credentialService, _connectionStore);
+ session.QueryEditor.Text = queryText;
+ var tab = CreateTab($"Query {_queryCounter}", session);
+ MainTabControl.Items.Add(tab);
+ MainTabControl.SelectedItem = tab;
+ UpdateEmptyOverlay();
+ };
var getActualPlanBtn = new Button
{
@@ -594,82 +606,9 @@ private DockPanel CreatePlanTabContent(PlanViewerControl viewer)
return panel;
}
- private void ShowAdviceWindow(string title, string content, AnalysisResult? analysis = null)
+ private void ShowAdviceWindow(string title, string content, AnalysisResult? analysis = null, PlanViewerControl? sourceViewer = null)
{
- var styledContent = AdviceContentBuilder.Build(content, analysis);
-
- var scrollViewer = new ScrollViewer
- {
- Content = styledContent,
- HorizontalScrollBarVisibility = Avalonia.Controls.Primitives.ScrollBarVisibility.Disabled,
- VerticalScrollBarVisibility = Avalonia.Controls.Primitives.ScrollBarVisibility.Auto
- };
-
- var copyBtn = new Button
- {
- Content = "Copy to Clipboard",
- Height = 32,
- Padding = new Avalonia.Thickness(16, 0),
- FontSize = 12,
- HorizontalContentAlignment = HorizontalAlignment.Center,
- VerticalContentAlignment = VerticalAlignment.Center,
- Theme = (Avalonia.Styling.ControlTheme)this.FindResource("AppButton")!
- };
-
- var closeBtn = new Button
- {
- Content = "Close",
- 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")!
- };
-
- var buttonPanel = new StackPanel
- {
- Orientation = Avalonia.Layout.Orientation.Horizontal,
- HorizontalAlignment = HorizontalAlignment.Right,
- Margin = new Avalonia.Thickness(0, 8, 0, 0)
- };
- buttonPanel.Children.Add(copyBtn);
- buttonPanel.Children.Add(closeBtn);
-
- var panel = new DockPanel { Margin = new Avalonia.Thickness(12) };
- DockPanel.SetDock(buttonPanel, Dock.Bottom);
- panel.Children.Add(buttonPanel);
- panel.Children.Add(scrollViewer);
-
- var window = new Window
- {
- Title = $"Performance Studio — {title}",
- Width = 700,
- Height = 600,
- MinWidth = 400,
- MinHeight = 300,
- Icon = this.Icon,
- Background = new SolidColorBrush(Color.Parse("#1A1D23")),
- Foreground = new SolidColorBrush(Color.Parse("#E4E6EB")),
- Content = panel
- };
-
- copyBtn.Click += async (_, _) =>
- {
- var clipboard = window.Clipboard;
- if (clipboard != null)
- {
- await clipboard.SetTextAsync(content);
- copyBtn.Content = "Copied!";
- await Task.Delay(1500);
- copyBtn.Content = "Copy to Clipboard";
- }
- };
-
- closeBtn.Click += (_, _) => window.Close();
-
- window.Show(this);
+ AdviceWindowHelper.Show(this, title, content, analysis, sourceViewer);
}
private List<(string label, PlanViewerControl viewer)> CollectAllPlanTabs()
diff --git a/src/PlanViewer.App/PlanViewer.App.csproj b/src/PlanViewer.App/PlanViewer.App.csproj
index 956357d..81fac66 100644
--- a/src/PlanViewer.App/PlanViewer.App.csproj
+++ b/src/PlanViewer.App/PlanViewer.App.csproj
@@ -6,7 +6,7 @@
app.manifest
EDD.ico
true
- 1.3.0
+ 1.4.0
Erik Darling
Darling Data LLC
Performance Studio
diff --git a/src/PlanViewer.App/Services/AdviceContentBuilder.cs b/src/PlanViewer.App/Services/AdviceContentBuilder.cs
index eeff7d6..60c971a 100644
--- a/src/PlanViewer.App/Services/AdviceContentBuilder.cs
+++ b/src/PlanViewer.App/Services/AdviceContentBuilder.cs
@@ -66,12 +66,23 @@ internal static class AdviceContentBuilder
private static readonly Regex CpuPercentRegex = new(@"(\d+)%\)", RegexOptions.Compiled);
+ // Matches "Node N" or "(Node N)" references in text
+ private static readonly Regex NodeRefRegex = new(@"(?<=\(?)\bNode\s+(\d+)\b(?=\)?)", RegexOptions.Compiled);
+
+ private static readonly SolidColorBrush LinkBrush = new(Color.Parse("#4FC3F7"));
+ private static readonly Avalonia.Input.Cursor HandCursor = new(Avalonia.Input.StandardCursorType.Hand);
+
public static StackPanel Build(string content)
{
- return Build(content, null);
+ return Build(content, null, null);
}
public static StackPanel Build(string content, AnalysisResult? analysis)
+ {
+ return Build(content, analysis, null);
+ }
+
+ public static StackPanel Build(string content, AnalysisResult? analysis, Action? onNodeClick)
{
var panel = new StackPanel { Margin = new Avalonia.Thickness(4, 0) };
var lines = content.Split('\n');
@@ -410,9 +421,260 @@ public static StackPanel Build(string content, AnalysisResult? analysis)
});
}
+ // Post-process: make "Node N" references clickable
+ if (onNodeClick != null)
+ MakeNodeRefsClickable(panel, onNodeClick);
+
return panel;
}
+ ///
+ /// Walks all children recursively and replaces "Node N" text with clickable inline links.
+ ///
+ private static void MakeNodeRefsClickable(Panel panel, Action onNodeClick)
+ {
+ for (int i = 0; i < panel.Children.Count; i++)
+ {
+ var child = panel.Children[i];
+
+ // Recurse into containers
+ if (child is Panel innerPanel)
+ {
+ MakeNodeRefsClickable(innerPanel, onNodeClick);
+ continue;
+ }
+ if (child is Border border)
+ {
+ if (border.Child is Panel borderPanel)
+ {
+ MakeNodeRefsClickable(borderPanel, onNodeClick);
+ continue;
+ }
+ if (border.Child is SelectableTextBlock borderStb)
+ {
+ if (borderStb.Inlines?.Count > 0)
+ ProcessInlines(borderStb, onNodeClick);
+ else if (!string.IsNullOrEmpty(borderStb.Text) && NodeRefRegex.IsMatch(borderStb.Text))
+ {
+ var bText = borderStb.Text;
+ var bFg = borderStb.Foreground;
+ borderStb.Text = null;
+ AddRunsWithNodeLinks(borderStb.Inlines!, bText, bFg, onNodeClick);
+ WireNodeClickHandler(borderStb, onNodeClick);
+ }
+ continue;
+ }
+ }
+ if (child is Expander expander && expander.Content is Panel expanderPanel)
+ {
+ MakeNodeRefsClickable(expanderPanel, onNodeClick);
+ continue;
+ }
+
+ // Process SelectableTextBlock with Inlines
+ if (child is SelectableTextBlock stb && stb.Inlines?.Count > 0)
+ {
+ ProcessInlines(stb, onNodeClick);
+ continue;
+ }
+
+ // Process SelectableTextBlock with plain Text
+ if (child is SelectableTextBlock stbPlain && stbPlain.Inlines?.Count == 0
+ && !string.IsNullOrEmpty(stbPlain.Text) && NodeRefRegex.IsMatch(stbPlain.Text))
+ {
+ var text = stbPlain.Text;
+ var fg = stbPlain.Foreground;
+ stbPlain.Text = null;
+ AddRunsWithNodeLinks(stbPlain.Inlines!, text, fg, onNodeClick);
+ WireNodeClickHandler(stbPlain, onNodeClick);
+ }
+ }
+ }
+
+ ///
+ /// Processes existing Inlines in a SelectableTextBlock, splitting any Run that
+ /// contains "Node N" into segments with clickable links.
+ ///
+ private static void ProcessInlines(SelectableTextBlock stb, Action onNodeClick)
+ {
+ var inlines = stb.Inlines!;
+ var snapshot = inlines.ToList();
+ var changed = false;
+
+ foreach (var inline in snapshot)
+ {
+ if (inline is Run run && !string.IsNullOrEmpty(run.Text) && NodeRefRegex.IsMatch(run.Text))
+ {
+ changed = true;
+ break;
+ }
+ }
+
+ if (!changed) return;
+
+ // Rebuild inlines
+ var newInlines = new List();
+ foreach (var inline in snapshot)
+ {
+ if (inline is Run run && !string.IsNullOrEmpty(run.Text) && NodeRefRegex.IsMatch(run.Text))
+ {
+ var text = run.Text;
+ int pos = 0;
+ foreach (System.Text.RegularExpressions.Match m in NodeRefRegex.Matches(text))
+ {
+ if (m.Index > pos)
+ newInlines.Add(new Run(text[pos..m.Index]) { Foreground = run.Foreground, FontWeight = run.FontWeight, FontSize = run.FontSize > 0 ? run.FontSize : double.NaN });
+
+ if (int.TryParse(m.Groups[1].Value, out var nodeId))
+ {
+ var linkRun = new Run(m.Value)
+ {
+ Foreground = LinkBrush,
+ TextDecorations = Avalonia.Media.TextDecorations.Underline,
+ FontWeight = run.FontWeight,
+ FontSize = run.FontSize > 0 ? run.FontSize : double.NaN
+ };
+ newInlines.Add(linkRun);
+ }
+ else
+ {
+ newInlines.Add(new Run(m.Value) { Foreground = run.Foreground, FontWeight = run.FontWeight });
+ }
+ pos = m.Index + m.Length;
+ }
+ if (pos < text.Length)
+ newInlines.Add(new Run(text[pos..]) { Foreground = run.Foreground, FontWeight = run.FontWeight, FontSize = run.FontSize > 0 ? run.FontSize : double.NaN });
+ }
+ else
+ {
+ newInlines.Add(inline);
+ }
+ }
+
+ inlines.Clear();
+ foreach (var ni in newInlines)
+ inlines.Add(ni);
+
+ // Wire up PointerPressed on the TextBlock to detect clicks on link runs
+ WireNodeClickHandler(stb, onNodeClick);
+ }
+
+ ///
+ /// Splits plain text into Runs, making "Node N" references clickable.
+ ///
+ private static void AddRunsWithNodeLinks(InlineCollection inlines, string text, IBrush? defaultFg, Action onNodeClick)
+ {
+ int pos = 0;
+ var stb = inlines.FirstOrDefault()?.Parent as SelectableTextBlock;
+ foreach (System.Text.RegularExpressions.Match m in NodeRefRegex.Matches(text))
+ {
+ if (m.Index > pos)
+ inlines.Add(new Run(text[pos..m.Index]) { Foreground = defaultFg });
+
+ if (int.TryParse(m.Groups[1].Value, out _))
+ {
+ inlines.Add(new Run(m.Value)
+ {
+ Foreground = LinkBrush,
+ TextDecorations = Avalonia.Media.TextDecorations.Underline
+ });
+ }
+ else
+ {
+ inlines.Add(new Run(m.Value) { Foreground = defaultFg });
+ }
+ pos = m.Index + m.Length;
+ }
+ if (pos < text.Length)
+ inlines.Add(new Run(text[pos..]) { Foreground = defaultFg });
+
+ // Find the parent SelectableTextBlock to attach click handler
+ // The inlines collection is owned by the SelectableTextBlock that called us
+ // We need to wire it up after — caller should call WireNodeClickHandler separately
+ }
+
+ ///
+ /// Attaches a PointerPressed handler to a SelectableTextBlock that detects clicks
+ /// on underlined "Node N" text and invokes the callback.
+ /// Uses Tunnel routing so the handler fires before SelectableTextBlock's
+ /// built-in text selection consumes the event.
+ ///
+ private static void WireNodeClickHandler(SelectableTextBlock stb, Action onNodeClick)
+ {
+ stb.AddHandler(Avalonia.Input.InputElement.PointerPressedEvent, (_, e) =>
+ {
+ var point = e.GetPosition(stb);
+ var hit = stb.TextLayout.HitTestPoint(point);
+ if (!hit.IsInside) return;
+
+ var charIndex = hit.TextPosition;
+
+ // Walk through inlines to find which Run the charIndex falls in
+ int runStart = 0;
+ foreach (var inline in stb.Inlines!)
+ {
+ if (inline is Run run && run.Text != null)
+ {
+ var runEnd = runStart + run.Text.Length;
+ if (charIndex >= runStart && charIndex < runEnd)
+ {
+ if (run.TextDecorations == Avalonia.Media.TextDecorations.Underline
+ && run.Foreground == LinkBrush)
+ {
+ var m = NodeRefRegex.Match(run.Text);
+ if (m.Success && int.TryParse(m.Groups[1].Value, out var nodeId))
+ {
+ e.Handled = true;
+
+ // Clear any text selection and release pointer capture
+ // to prevent SelectableTextBlock from starting a selection drag
+ stb.SelectionStart = 0;
+ stb.SelectionEnd = 0;
+ e.Pointer.Capture(null);
+
+ onNodeClick(nodeId);
+ }
+ }
+ return;
+ }
+ runStart = runEnd;
+ }
+ }
+ }, Avalonia.Interactivity.RoutingStrategies.Tunnel);
+
+ // Change cursor on hover over link runs
+ stb.PointerMoved += (_, e) =>
+ {
+ var point = e.GetPosition(stb);
+ var hit = stb.TextLayout.HitTestPoint(point);
+ if (!hit.IsInside)
+ {
+ stb.Cursor = Avalonia.Input.Cursor.Default;
+ return;
+ }
+
+ var charIndex = hit.TextPosition;
+ int runStart = 0;
+ foreach (var inline in stb.Inlines!)
+ {
+ if (inline is Run run && run.Text != null)
+ {
+ var runEnd = runStart + run.Text.Length;
+ if (charIndex >= runStart && charIndex < runEnd)
+ {
+ stb.Cursor = run.TextDecorations == Avalonia.Media.TextDecorations.Underline
+ && run.Foreground == LinkBrush
+ ? HandCursor
+ : Avalonia.Input.Cursor.Default;
+ return;
+ }
+ runStart = runEnd;
+ }
+ }
+ stb.Cursor = Avalonia.Input.Cursor.Default;
+ };
+ }
+
private static bool IsSubSectionLabel(string trimmed)
{
// "Warnings:", "Parameters:", "Wait stats:", "Operator warnings:",
diff --git a/src/PlanViewer.App/Services/AdviceWindowHelper.cs b/src/PlanViewer.App/Services/AdviceWindowHelper.cs
new file mode 100644
index 0000000..372fd15
--- /dev/null
+++ b/src/PlanViewer.App/Services/AdviceWindowHelper.cs
@@ -0,0 +1,128 @@
+using System;
+using System.Threading.Tasks;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+using Avalonia.Layout;
+using Avalonia.Media;
+using PlanViewer.App.Controls;
+using PlanViewer.Core.Output;
+
+namespace PlanViewer.App.Services;
+
+///
+/// Creates and shows the Advice for Humans / Robots popup window.
+/// Shared between MainWindow (file mode) and QuerySessionControl (query mode).
+///
+internal static class AdviceWindowHelper
+{
+ public static void Show(
+ Window owner,
+ string title,
+ string content,
+ AnalysisResult? analysis = null,
+ PlanViewerControl? sourceViewer = null)
+ {
+ Action? onNodeClick = sourceViewer != null
+ ? nodeId => sourceViewer.NavigateToNode(nodeId)
+ : null;
+ var styledContent = AdviceContentBuilder.Build(content, analysis, onNodeClick);
+
+ var scrollViewer = new ScrollViewer
+ {
+ Content = styledContent,
+ HorizontalScrollBarVisibility = Avalonia.Controls.Primitives.ScrollBarVisibility.Disabled,
+ VerticalScrollBarVisibility = Avalonia.Controls.Primitives.ScrollBarVisibility.Auto
+ };
+
+ var buttonTheme = (Avalonia.Styling.ControlTheme)owner.FindResource("AppButton")!;
+
+ var copyBtn = new Button
+ {
+ Content = "Copy to Clipboard",
+ Height = 32,
+ Padding = new Avalonia.Thickness(16, 0),
+ FontSize = 12,
+ HorizontalContentAlignment = HorizontalAlignment.Center,
+ VerticalContentAlignment = VerticalAlignment.Center,
+ Theme = buttonTheme
+ };
+
+ var closeBtn = new Button
+ {
+ Content = "Close",
+ 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 = buttonTheme
+ };
+
+ var buttonPanel = new StackPanel
+ {
+ Orientation = Orientation.Horizontal,
+ HorizontalAlignment = HorizontalAlignment.Right,
+ Margin = new Avalonia.Thickness(0, 8, 0, 0)
+ };
+ buttonPanel.Children.Add(copyBtn);
+ buttonPanel.Children.Add(closeBtn);
+
+ // Wrap in LayoutTransformControl for Ctrl+Wheel font scaling
+ var scaleTransform = new ScaleTransform(1, 1);
+ var layoutTransform = new LayoutTransformControl
+ {
+ LayoutTransform = scaleTransform,
+ Child = scrollViewer
+ };
+
+ var panel = new DockPanel { Margin = new Avalonia.Thickness(12) };
+ DockPanel.SetDock(buttonPanel, Dock.Bottom);
+ panel.Children.Add(buttonPanel);
+ panel.Children.Add(layoutTransform);
+
+ var window = new Window
+ {
+ Title = $"Performance Studio \u2014 {title}",
+ Width = 700,
+ Height = 600,
+ MinWidth = 400,
+ MinHeight = 300,
+ Icon = owner.Icon,
+ Background = new SolidColorBrush(Color.Parse("#1A1D23")),
+ Foreground = new SolidColorBrush(Color.Parse("#E4E6EB")),
+ Content = panel
+ };
+
+ // Ctrl+MouseWheel to increase/decrease font size
+ double adviceZoom = 1.0;
+ window.AddHandler(InputElement.PointerWheelChangedEvent, (_, args) =>
+ {
+ if (args.KeyModifiers.HasFlag(KeyModifiers.Control))
+ {
+ args.Handled = true;
+ adviceZoom += args.Delta.Y > 0 ? 0.1 : -0.1;
+ adviceZoom = Math.Max(0.5, Math.Min(3.0, adviceZoom));
+ scaleTransform.ScaleX = adviceZoom;
+ scaleTransform.ScaleY = adviceZoom;
+ }
+ }, RoutingStrategies.Tunnel);
+
+ copyBtn.Click += async (_, _) =>
+ {
+ var clipboard = window.Clipboard;
+ if (clipboard != null)
+ {
+ await clipboard.SetTextAsync(content);
+ copyBtn.Content = "Copied!";
+ await Task.Delay(1500);
+ copyBtn.Content = "Copy to Clipboard";
+ }
+ };
+
+ closeBtn.Click += (_, _) => window.Close();
+
+ window.Show(owner);
+ }
+}
diff --git a/src/PlanViewer.Core/Models/PlanModels.cs b/src/PlanViewer.Core/Models/PlanModels.cs
index 0f15fbd..ec0201b 100644
--- a/src/PlanViewer.Core/Models/PlanModels.cs
+++ b/src/PlanViewer.Core/Models/PlanModels.cs
@@ -338,6 +338,10 @@ public class PlanNode
public int NoMatchingIndexCount { get; set; }
public int PartialMatchingIndexCount { get; set; }
+ // Modification operator: nonclustered indexes maintained
+ public int NonClusteredIndexCount { get; set; }
+ public List NonClusteredIndexNames { get; set; } = new();
+
// ConstantScan Values (parsed rows as displayable string)
public string? ConstantScanValues { get; set; }
diff --git a/src/PlanViewer.Core/Models/QueryStoreHistoryRow.cs b/src/PlanViewer.Core/Models/QueryStoreHistoryRow.cs
index b38c6ba..b275acf 100644
--- a/src/PlanViewer.Core/Models/QueryStoreHistoryRow.cs
+++ b/src/PlanViewer.Core/Models/QueryStoreHistoryRow.cs
@@ -23,11 +23,25 @@ public class QueryStoreHistoryRow
public double TotalLogicalReads { get; set; }
public double TotalLogicalWrites { get; set; }
public double TotalPhysicalReads { get; set; }
-
- public int MinDop { get; set; }
+ public double TotalMemoryMb { get; set; }
+ public int MinDop { get; set; }
public int MaxDop { get; set; }
public DateTime? LastExecutionUtc { get; set; }
- public string IntervalStartLocal => TimeDisplayHelper.FormatForDisplay(IntervalStartUtc);
+ // Display-formatted properties (2 decimal places)
+ public string AvgDurationMsDisplay => AvgDurationMs.ToString("N2");
+ public string AvgCpuMsDisplay => AvgCpuMs.ToString("N2");
+ public string AvgLogicalReadsDisplay => AvgLogicalReads.ToString("N2");
+ public string AvgLogicalWritesDisplay => AvgLogicalWrites.ToString("N2");
+ public string AvgPhysicalReadsDisplay => AvgPhysicalReads.ToString("N2");
+ public string AvgMemoryMbDisplay => AvgMemoryMb.ToString("N2");
+ public string AvgRowcountDisplay => AvgRowcount.ToString("N2");
+ public string TotalDurationMsDisplay => TotalDurationMs.ToString("N2");
+ public string TotalCpuMsDisplay => TotalCpuMs.ToString("N2");
+ public string TotalLogicalReadsDisplay => TotalLogicalReads.ToString("N0");
+ public string TotalLogicalWritesDisplay => TotalLogicalWrites.ToString("N0");
+ public string TotalPhysicalReadsDisplay => TotalPhysicalReads.ToString("N0");
+ public string TotalMemoryMbDisplay => TotalMemoryMb.ToString("N2");
+ public string IntervalStartLocal => TimeDisplayHelper.FormatForDisplay(IntervalStartUtc);
public string LastExecutionLocal => LastExecutionUtc.HasValue ? TimeDisplayHelper.FormatForDisplay(LastExecutionUtc.Value) : "";
}
diff --git a/src/PlanViewer.Core/PlanViewer.Core.csproj b/src/PlanViewer.Core/PlanViewer.Core.csproj
index 1a3c43a..b91d4dc 100644
--- a/src/PlanViewer.Core/PlanViewer.Core.csproj
+++ b/src/PlanViewer.Core/PlanViewer.Core.csproj
@@ -5,7 +5,7 @@
enable
enable
PlanViewer.Core
- 1.3.0
+ 1.4.0
Erik Darling
Darling Data LLC
SQL Performance Studio
@@ -15,6 +15,7 @@
+
diff --git a/src/PlanViewer.Core/Services/PlanAnalyzer.cs b/src/PlanViewer.Core/Services/PlanAnalyzer.cs
index 0d577b3..f4c829d 100644
--- a/src/PlanViewer.Core/Services/PlanAnalyzer.cs
+++ b/src/PlanViewer.Core/Services/PlanAnalyzer.cs
@@ -460,7 +460,7 @@ private static void AnalyzeNode(PlanNode node, PlanStatement stmt, AnalyzerConfi
// Rule 2: Eager Index Spools — optimizer building temporary indexes on the fly
if (!cfg.IsRuleDisabled(2) && node.LogicalOp == "Eager Spool" &&
- node.PhysicalOp.Contains("Spool", StringComparison.OrdinalIgnoreCase))
+ 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))
diff --git a/src/PlanViewer.Core/Services/QueryStoreService.cs b/src/PlanViewer.Core/Services/QueryStoreService.cs
index 7692981..8b96fbd 100644
--- a/src/PlanViewer.Core/Services/QueryStoreService.cs
+++ b/src/PlanViewer.Core/Services/QueryStoreService.cs
@@ -432,6 +432,234 @@ JOIN sys.query_store_plan p
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)
+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,
+ });
+ }
+
+ return rows;
+ }
+
+ ///
+ /// Fetches interval-level history rows for all queries sharing the given query_hash,
+ /// grouped by query_plan_hash and interval start.
+ /// Smart aggregation: SUM for totals/executions, weighted AVG for averages, MAX for last_execution.
+ /// When / are provided they define the
+ /// time window; otherwise falls back to .
+ ///
+ public static async Task> FetchAggregateHistoryAsync(
+ 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
+ CONVERT(varchar(18), 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),
+ SUM(rs.avg_query_max_used_memory * rs.count_executions)
+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.query_plan_hash, rsi.start_time
+ORDER BY rsi.start_time, p.query_plan_hash;";
+
+ 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
+ {
+ QueryPlanHash = reader.IsDBNull(0) ? "" : reader.GetString(0),
+ IntervalStartUtc = ((DateTimeOffset)reader.GetValue(1)).UtcDateTime,
+ CountExecutions = reader.GetInt64(2),
+ AvgDurationMs = reader.GetDouble(3),
+ AvgCpuMs = reader.GetDouble(4),
+ AvgLogicalReads = reader.GetDouble(5),
+ AvgLogicalWrites = reader.GetDouble(6),
+ AvgPhysicalReads = reader.GetDouble(7),
+ AvgMemoryMb = reader.GetDouble(8),
+ AvgRowcount = reader.GetDouble(9),
+ TotalDurationMs = reader.GetDouble(10),
+ TotalCpuMs = reader.GetDouble(11),
+ TotalLogicalReads = reader.GetDouble(12),
+ TotalLogicalWrites = reader.GetDouble(13),
+ TotalPhysicalReads = reader.GetDouble(14),
+ MinDop = (int)reader.GetInt64(15),
+ MaxDop = (int)reader.GetInt64(16),
+ LastExecutionUtc = reader.IsDBNull(17) ? null : ((DateTimeOffset)reader.GetValue(17)).UtcDateTime,
+ TotalMemoryMb = reader.GetDouble(18),
+ });
+ }
+
+ return rows;
+ }
+
///
/// Fetches hourly-aggregated metric data for the time-range slicer.
/// Limits data to the last days (default 30).
diff --git a/src/PlanViewer.Core/Services/SchemaQueryService.cs b/src/PlanViewer.Core/Services/SchemaQueryService.cs
new file mode 100644
index 0000000..2105654
--- /dev/null
+++ b/src/PlanViewer.Core/Services/SchemaQueryService.cs
@@ -0,0 +1,240 @@
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Data.SqlClient;
+
+namespace PlanViewer.Core.Services;
+
+public sealed class IndexInfo
+{
+ public required string IndexName { get; init; }
+ public required string IndexType { get; init; }
+ public required bool IsUnique { get; init; }
+ public required bool IsPrimaryKey { get; init; }
+ public required string KeyColumns { get; init; }
+ public required string IncludeColumns { get; init; }
+ public required string? FilterDefinition { get; init; }
+ public required long RowCount { get; init; }
+ public required double SizeMB { get; init; }
+ public long UserSeeks { get; init; }
+ public long UserScans { get; init; }
+ public long UserLookups { get; init; }
+ public long UserUpdates { get; init; }
+ public int FillFactor { get; init; }
+ public bool IsPadded { get; init; }
+ public bool AllowRowLocks { get; init; } = true;
+ public bool AllowPageLocks { get; init; } = true;
+ public bool IsDisabled { get; init; }
+ public required string DataCompression { get; init; }
+ public string? PartitionScheme { get; init; }
+ public string? PartitionColumn { get; init; }
+}
+
+public sealed class ColumnInfo
+{
+ public required int OrdinalPosition { get; init; }
+ public required string ColumnName { get; init; }
+ public required string DataType { get; init; }
+ public required bool IsNullable { get; init; }
+ public required bool IsIdentity { get; init; }
+ public required bool IsComputed { get; init; }
+ public required string? DefaultValue { get; init; }
+ public required string? ComputedDefinition { get; init; }
+ public required long IdentitySeed { get; init; }
+ public required long IdentityIncrement { get; init; }
+}
+
+///
+/// Fetches schema information (indexes, columns, object definitions) from a connected SQL Server.
+///
+public static class SchemaQueryService
+{
+ private const string IndexQuery = @"
+SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
+SELECT
+ i.name AS index_name,
+ i.type_desc AS index_type,
+ i.is_unique,
+ i.is_primary_key,
+ STUFF((
+ SELECT ', ' + c.name + CASE WHEN ic2.is_descending_key = 1 THEN ' DESC' ELSE '' END
+ FROM sys.index_columns AS ic2
+ JOIN sys.columns AS c ON c.object_id = ic2.object_id AND c.column_id = ic2.column_id
+ WHERE ic2.object_id = i.object_id AND ic2.index_id = i.index_id AND ic2.is_included_column = 0
+ ORDER BY ic2.key_ordinal
+ FOR XML PATH('')
+ ), 1, 2, '') AS key_columns,
+ ISNULL(STUFF((
+ SELECT ', ' + c.name
+ FROM sys.index_columns AS ic2
+ JOIN sys.columns AS c ON c.object_id = ic2.object_id AND c.column_id = ic2.column_id
+ WHERE ic2.object_id = i.object_id AND ic2.index_id = i.index_id AND ic2.is_included_column = 1
+ ORDER BY c.name
+ FOR XML PATH('')
+ ), 1, 2, ''), '') AS include_columns,
+ i.filter_definition,
+ p.row_count,
+ CAST(ROUND(p.reserved_page_count * 8.0 / 1024, 2) AS float) AS size_mb,
+ ISNULL(us.user_seeks, 0) AS user_seeks,
+ ISNULL(us.user_scans, 0) AS user_scans,
+ ISNULL(us.user_lookups, 0) AS user_lookups,
+ ISNULL(us.user_updates, 0) AS user_updates,
+ CAST(i.fill_factor AS int),
+ i.is_padded,
+ i.allow_row_locks,
+ i.allow_page_locks,
+ i.is_disabled,
+ ISNULL(p.data_compression_desc, 'NONE') AS data_compression,
+ psch.name AS partition_scheme,
+ pc.name AS partition_column
+FROM sys.indexes AS i
+CROSS APPLY (
+ SELECT SUM(ps.row_count) AS row_count,
+ SUM(ps.reserved_page_count) AS reserved_page_count,
+ MAX(pt.data_compression_desc) AS data_compression_desc
+ FROM sys.dm_db_partition_stats AS ps
+ JOIN sys.partitions AS pt ON pt.partition_id = ps.partition_id
+ WHERE ps.object_id = i.object_id AND ps.index_id = i.index_id
+) AS p
+LEFT JOIN sys.dm_db_index_usage_stats AS us
+ ON us.object_id = i.object_id AND us.index_id = i.index_id AND us.database_id = DB_ID()
+LEFT JOIN sys.partition_schemes AS psch
+ ON psch.data_space_id = i.data_space_id
+LEFT JOIN sys.index_columns AS pic
+ ON pic.object_id = i.object_id AND pic.index_id = i.index_id AND pic.partition_ordinal > 0
+LEFT JOIN sys.columns AS pc
+ ON pc.object_id = pic.object_id AND pc.column_id = pic.column_id
+WHERE i.object_id = OBJECT_ID(@objectName)
+ AND i.type > 0
+ORDER BY i.index_id;";
+
+ private const string ColumnQuery = @"
+SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
+SELECT
+ c.column_id AS ordinal_position,
+ c.name AS column_name,
+ tp.name +
+ CASE
+ WHEN tp.name IN ('varchar','nvarchar','char','nchar','binary','varbinary')
+ THEN '(' + CASE WHEN c.max_length = -1 THEN 'max' ELSE CAST(
+ CASE WHEN tp.name IN ('nvarchar','nchar') THEN c.max_length / 2 ELSE c.max_length END
+ AS varchar) END + ')'
+ WHEN tp.name IN ('decimal','numeric')
+ THEN '(' + CAST(c.precision AS varchar) + ',' + CAST(c.scale AS varchar) + ')'
+ WHEN tp.name IN ('datetime2','datetimeoffset','time')
+ THEN '(' + CAST(c.scale AS varchar) + ')'
+ ELSE ''
+ END AS data_type,
+ c.is_nullable,
+ c.is_identity,
+ c.is_computed,
+ dc.definition AS default_value,
+ cc.definition AS computed_definition,
+ CAST(ISNULL(ic.seed_value, 0) AS bigint) AS identity_seed,
+ CAST(ISNULL(ic.increment_value, 0) AS bigint) AS identity_increment
+FROM sys.columns AS c
+JOIN sys.types AS tp ON tp.user_type_id = c.user_type_id
+LEFT JOIN sys.default_constraints AS dc ON dc.parent_object_id = c.object_id AND dc.parent_column_id = c.column_id
+LEFT JOIN sys.computed_columns AS cc ON cc.object_id = c.object_id AND cc.column_id = c.column_id
+LEFT JOIN sys.identity_columns AS ic ON ic.object_id = c.object_id AND ic.column_id = c.column_id
+WHERE c.object_id = OBJECT_ID(@objectName)
+ORDER BY c.column_id;";
+
+ private const string ObjectDefinitionQuery = @"
+SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
+SELECT OBJECT_DEFINITION(OBJECT_ID(@objectName));";
+
+ public static async Task> FetchIndexesAsync(
+ string connectionString, string objectName, CancellationToken ct = default)
+ {
+ var results = new List();
+
+ await using var conn = new SqlConnection(connectionString);
+ await conn.OpenAsync(ct);
+
+ await using var cmd = new SqlCommand(IndexQuery, conn);
+ cmd.CommandTimeout = 10;
+ cmd.Parameters.AddWithValue("@objectName", objectName);
+
+ await using var reader = await cmd.ExecuteReaderAsync(ct);
+
+ while (await reader.ReadAsync(ct))
+ {
+ results.Add(new IndexInfo
+ {
+ IndexName = reader.GetString(0),
+ IndexType = reader.GetString(1),
+ IsUnique = reader.GetBoolean(2),
+ IsPrimaryKey = reader.GetBoolean(3),
+ KeyColumns = reader.IsDBNull(4) ? "" : reader.GetString(4),
+ IncludeColumns = reader.IsDBNull(5) ? "" : reader.GetString(5),
+ FilterDefinition = reader.IsDBNull(6) ? null : reader.GetString(6),
+ RowCount = reader.GetInt64(7),
+ SizeMB = reader.GetDouble(8),
+ UserSeeks = reader.GetInt64(9),
+ UserScans = reader.GetInt64(10),
+ UserLookups = reader.GetInt64(11),
+ UserUpdates = reader.GetInt64(12),
+ FillFactor = reader.GetInt32(13),
+ IsPadded = reader.GetBoolean(14),
+ AllowRowLocks = reader.GetBoolean(15),
+ AllowPageLocks = reader.GetBoolean(16),
+ IsDisabled = reader.GetBoolean(17),
+ DataCompression = reader.GetString(18),
+ PartitionScheme = reader.IsDBNull(19) ? null : reader.GetString(19),
+ PartitionColumn = reader.IsDBNull(20) ? null : reader.GetString(20)
+ });
+ }
+
+ return results;
+ }
+
+ public static async Task> FetchColumnsAsync(
+ string connectionString, string objectName, CancellationToken ct = default)
+ {
+ var results = new List();
+
+ await using var conn = new SqlConnection(connectionString);
+ await conn.OpenAsync(ct);
+
+ await using var cmd = new SqlCommand(ColumnQuery, conn);
+ cmd.CommandTimeout = 10;
+ cmd.Parameters.AddWithValue("@objectName", objectName);
+
+ await using var reader = await cmd.ExecuteReaderAsync(ct);
+
+ while (await reader.ReadAsync(ct))
+ {
+ results.Add(new ColumnInfo
+ {
+ OrdinalPosition = reader.GetInt32(0),
+ ColumnName = reader.GetString(1),
+ DataType = reader.GetString(2),
+ IsNullable = reader.GetBoolean(3),
+ IsIdentity = reader.GetBoolean(4),
+ IsComputed = reader.GetBoolean(5),
+ DefaultValue = reader.IsDBNull(6) ? null : reader.GetString(6),
+ ComputedDefinition = reader.IsDBNull(7) ? null : reader.GetString(7),
+ IdentitySeed = reader.GetInt64(8),
+ IdentityIncrement = reader.GetInt64(9)
+ });
+ }
+
+ return results;
+ }
+
+ public static async Task FetchObjectDefinitionAsync(
+ string connectionString, string objectName, CancellationToken ct = default)
+ {
+ await using var conn = new SqlConnection(connectionString);
+ await conn.OpenAsync(ct);
+
+ await using var cmd = new SqlCommand(ObjectDefinitionQuery, conn);
+ cmd.CommandTimeout = 10;
+ cmd.Parameters.AddWithValue("@objectName", objectName);
+
+ var result = await cmd.ExecuteScalarAsync(ct);
+
+ return result as string;
+ }
+}
diff --git a/src/PlanViewer.Core/Services/ShowPlanParser.cs b/src/PlanViewer.Core/Services/ShowPlanParser.cs
index 637df45..5cf9f11 100644
--- a/src/PlanViewer.Core/Services/ShowPlanParser.cs
+++ b/src/PlanViewer.Core/Services/ShowPlanParser.cs
@@ -691,6 +691,22 @@ private static PlanNode ParseRelOp(XElement relOpEl)
node.TableReferenceId = (int)ParseDouble(objEl.Attribute("TableReferenceId")?.Value);
}
+ // Nonclustered indexes maintained by modification operators (Update/SimpleUpdate)
+ var opName = physicalOpEl.Name.LocalName;
+ if (opName is "Update" or "SimpleUpdate" or "CreateIndex")
+ {
+ var ncObjects = ScopedDescendants(physicalOpEl, Ns + "Object")
+ .Where(o => string.Equals(o.Attribute("IndexKind")?.Value, "NonClustered", StringComparison.OrdinalIgnoreCase))
+ .ToList();
+ node.NonClusteredIndexCount = ncObjects.Count;
+ foreach (var ncObj in ncObjects)
+ {
+ var ixName = ncObj.Attribute("Index")?.Value?.Replace("[", "").Replace("]", "");
+ if (!string.IsNullOrEmpty(ixName))
+ node.NonClusteredIndexNames.Add(ixName);
+ }
+ }
+
// Hash keys for hash match operators
var hashKeysProbeEl = physicalOpEl.Element(Ns + "HashKeysProbe");
if (hashKeysProbeEl != null)
diff --git a/src/PlanViewer.Core/Services/SqlObjectResolver.cs b/src/PlanViewer.Core/Services/SqlObjectResolver.cs
new file mode 100644
index 0000000..07b402d
--- /dev/null
+++ b/src/PlanViewer.Core/Services/SqlObjectResolver.cs
@@ -0,0 +1,248 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using Microsoft.SqlServer.TransactSql.ScriptDom;
+
+namespace PlanViewer.Core.Services;
+
+///
+/// The kind of SQL object found at a cursor position.
+///
+public enum SqlObjectKind
+{
+ Table,
+ View,
+ Function,
+ Procedure,
+ Unknown
+}
+
+///
+/// Represents a resolved SQL object at a specific cursor position.
+///
+public sealed class ResolvedSqlObject
+{
+ public required string SchemaName { get; init; }
+ public required string ObjectName { get; init; }
+ public required SqlObjectKind Kind { get; init; }
+
+ ///
+ /// Fully qualified [schema].[object] name.
+ ///
+ public string FullName => string.IsNullOrEmpty(SchemaName)
+ ? ObjectName
+ : $"{SchemaName}.{ObjectName}";
+}
+
+///
+/// Parses T-SQL text using ScriptDom and resolves what object is at a given cursor offset.
+///
+public static class SqlObjectResolver
+{
+ ///
+ /// Resolve the SQL object at the given zero-based character offset in the SQL text.
+ /// Returns null if no recognizable object is at that position.
+ ///
+ public static ResolvedSqlObject? Resolve(string sqlText, int offset)
+ {
+ if (string.IsNullOrWhiteSpace(sqlText) || offset < 0 || offset >= sqlText.Length)
+ return null;
+
+ var parser = new TSql160Parser(initialQuotedIdentifiers: false);
+
+ using var reader = new StringReader(sqlText);
+ var fragment = parser.Parse(reader, out var errors);
+
+ if (fragment == null)
+ return null;
+
+ var visitor = new ObjectAtOffsetVisitor(offset);
+ fragment.Accept(visitor);
+
+ return visitor.Result;
+ }
+
+ ///
+ /// Resolve all distinct SQL objects referenced in the text.
+ ///
+ public static IReadOnlyList ResolveAll(string sqlText)
+ {
+ if (string.IsNullOrWhiteSpace(sqlText))
+ return Array.Empty();
+
+ var parser = new TSql160Parser(initialQuotedIdentifiers: false);
+
+ using var reader = new StringReader(sqlText);
+ var fragment = parser.Parse(reader, out var errors);
+
+ if (fragment == null)
+ return Array.Empty();
+
+ var visitor = new AllObjectsVisitor();
+ fragment.Accept(visitor);
+
+ return visitor.Results;
+ }
+
+ ///
+ /// Walks the AST to find the object reference whose token span covers the given offset.
+ ///
+ private sealed class ObjectAtOffsetVisitor : TSqlFragmentVisitor
+ {
+ private readonly int _offset;
+
+ public ResolvedSqlObject? Result { get; private set; }
+
+ public ObjectAtOffsetVisitor(int offset) => _offset = offset;
+
+ public override void Visit(NamedTableReference node)
+ {
+ if (Covers(node.SchemaObject, _offset))
+ {
+ Result = FromSchemaObjectName(node.SchemaObject, SqlObjectKind.Table);
+ }
+ }
+
+ public override void Visit(FunctionCall node)
+ {
+ if (node.CallTarget is MultiPartIdentifierCallTarget target)
+ {
+ if (CoversIdentifier(target.MultiPartIdentifier, _offset) ||
+ CoversToken(node.FunctionName, _offset))
+ {
+ Result = FromFunctionCall(target.MultiPartIdentifier, node.FunctionName.Value, SqlObjectKind.Function);
+ }
+ }
+ else if (CoversToken(node.FunctionName, _offset))
+ {
+ // Standalone function name — could be a scalar UDF or built-in
+ Result = new ResolvedSqlObject
+ {
+ SchemaName = "",
+ ObjectName = node.FunctionName.Value,
+ Kind = SqlObjectKind.Function
+ };
+ }
+ }
+
+ public override void Visit(SchemaObjectFunctionTableReference node)
+ {
+ if (Covers(node.SchemaObject, _offset))
+ {
+ Result = FromSchemaObjectName(node.SchemaObject, SqlObjectKind.Function);
+ }
+ }
+
+ public override void Visit(ExecutableProcedureReference node)
+ {
+ if (node.ProcedureReference?.ProcedureReference != null &&
+ Covers(node.ProcedureReference.ProcedureReference.Name, _offset))
+ {
+ Result = FromSchemaObjectName(
+ node.ProcedureReference.ProcedureReference.Name,
+ SqlObjectKind.Procedure);
+ }
+ }
+ }
+
+ ///
+ /// Collects all distinct object references in the SQL text.
+ ///
+ private sealed class AllObjectsVisitor : TSqlFragmentVisitor
+ {
+ private readonly HashSet _seen = new(StringComparer.OrdinalIgnoreCase);
+ private readonly List _results = new();
+
+ public IReadOnlyList Results => _results;
+
+ public override void Visit(NamedTableReference node)
+ {
+ Add(FromSchemaObjectName(node.SchemaObject, SqlObjectKind.Table));
+ }
+
+ public override void Visit(SchemaObjectFunctionTableReference node)
+ {
+ Add(FromSchemaObjectName(node.SchemaObject, SqlObjectKind.Function));
+ }
+
+ public override void Visit(ExecutableProcedureReference node)
+ {
+ if (node.ProcedureReference?.ProcedureReference?.Name != null)
+ {
+ Add(FromSchemaObjectName(
+ node.ProcedureReference.ProcedureReference.Name,
+ SqlObjectKind.Procedure));
+ }
+ }
+
+ private void Add(ResolvedSqlObject obj)
+ {
+ if (_seen.Add(obj.FullName))
+ _results.Add(obj);
+ }
+ }
+
+ // --- Helpers ---
+
+ private static ResolvedSqlObject FromSchemaObjectName(SchemaObjectName name, SqlObjectKind kind)
+ {
+ return new ResolvedSqlObject
+ {
+ SchemaName = name.SchemaIdentifier?.Value ?? "",
+ ObjectName = name.BaseIdentifier?.Value ?? "",
+ Kind = kind
+ };
+ }
+
+ private static ResolvedSqlObject FromFunctionCall(
+ MultiPartIdentifier identifier, string functionName, SqlObjectKind kind)
+ {
+ // e.g., dbo.MyFunction() → schema = "dbo", object = "MyFunction"
+ var parts = identifier.Identifiers;
+ var schema = parts.Count > 0 ? parts[parts.Count - 1].Value : "";
+
+ return new ResolvedSqlObject
+ {
+ SchemaName = schema,
+ ObjectName = functionName,
+ Kind = kind
+ };
+ }
+
+ ///
+ /// Check if a SchemaObjectName's token range covers the given offset.
+ ///
+ private static bool Covers(SchemaObjectName name, int offset)
+ {
+ if (name == null) return false;
+
+ int start = name.StartOffset;
+ int end = start + name.FragmentLength;
+ return offset >= start && offset < end;
+ }
+
+ ///
+ /// Check if a MultiPartIdentifier's token range covers the given offset.
+ ///
+ private static bool CoversIdentifier(MultiPartIdentifier identifier, int offset)
+ {
+ if (identifier == null) return false;
+
+ int start = identifier.StartOffset;
+ int end = start + identifier.FragmentLength;
+ return offset >= start && offset < end;
+ }
+
+ ///
+ /// Check if a single Identifier token covers the given offset.
+ ///
+ private static bool CoversToken(Identifier token, int offset)
+ {
+ if (token == null) return false;
+
+ int start = token.StartOffset;
+ int end = start + token.FragmentLength;
+ return offset >= start && offset < end;
+ }
+}
diff --git a/tests/PlanViewer.Core.Tests/NonClusteredIndexCountTests.cs b/tests/PlanViewer.Core.Tests/NonClusteredIndexCountTests.cs
new file mode 100644
index 0000000..5df2bd3
--- /dev/null
+++ b/tests/PlanViewer.Core.Tests/NonClusteredIndexCountTests.cs
@@ -0,0 +1,54 @@
+using PlanViewer.Core.Models;
+
+namespace PlanViewer.Core.Tests;
+
+public class NonClusteredIndexCountTests
+{
+ [Fact]
+ public void Update_WithFiveNonClusteredIndexes_CountIsFive()
+ {
+ var plan = PlanTestHelper.LoadAndAnalyze("multi_index_update_plan.sqlplan");
+ var stmt = PlanTestHelper.FirstStatement(plan);
+ var updateNode = PlanTestHelper.FindNode(stmt.RootNode!, 1)!;
+
+ Assert.Contains("Update", updateNode.PhysicalOp, StringComparison.OrdinalIgnoreCase);
+ Assert.Equal(5, updateNode.NonClusteredIndexCount);
+ }
+
+ [Fact]
+ public void Insert_WithFiveNonClusteredIndexes_CountIsFive()
+ {
+ var plan = PlanTestHelper.LoadAndAnalyze("multi_index_insert_plan.sqlplan");
+ var stmt = PlanTestHelper.FirstStatement(plan);
+ var insertNode = PlanTestHelper.FindNode(stmt.RootNode!, 0)!;
+
+ Assert.Contains("Insert", insertNode.PhysicalOp, StringComparison.OrdinalIgnoreCase);
+ Assert.Equal(5, insertNode.NonClusteredIndexCount);
+ }
+
+ [Fact]
+ public void Delete_WithFiveNonClusteredIndexes_CountIsFive()
+ {
+ var plan = PlanTestHelper.LoadAndAnalyze("multi_index_delete_plan.sqlplan");
+ var stmt = PlanTestHelper.FirstStatement(plan);
+ var deleteNode = PlanTestHelper.FindNode(stmt.RootNode!, 0)!;
+
+ Assert.Contains("Delete", deleteNode.PhysicalOp, StringComparison.OrdinalIgnoreCase);
+ Assert.Equal(5, deleteNode.NonClusteredIndexCount);
+ }
+
+ [Fact]
+ public void ReadOperator_HasZeroNonClusteredIndexCount()
+ {
+ var plan = PlanTestHelper.LoadAndAnalyze("key_lookup_plan.sqlplan");
+ var stmt = PlanTestHelper.FirstStatement(plan);
+
+ void AssertZero(PlanNode node)
+ {
+ Assert.Equal(0, node.NonClusteredIndexCount);
+ foreach (var child in node.Children)
+ AssertZero(child);
+ }
+ AssertZero(stmt.RootNode!);
+ }
+}
diff --git a/tests/PlanViewer.Core.Tests/PlanAnalyzerTests.cs b/tests/PlanViewer.Core.Tests/PlanAnalyzerTests.cs
index e8815ab..28ad002 100644
--- a/tests/PlanViewer.Core.Tests/PlanAnalyzerTests.cs
+++ b/tests/PlanViewer.Core.Tests/PlanAnalyzerTests.cs
@@ -33,6 +33,17 @@ public void Rule02_EagerIndexSpool_DetectedInLazySpoolPlan()
Assert.Contains(warnings, w => w.Message.Contains("temporary index in TempDB"));
}
+ [Fact]
+ public void Rule02_EagerIndexSpool_NotFiredForEagerTableSpool()
+ {
+ // Plan with Eager Table Spool (PhysicalOp="Table Spool", LogicalOp="Eager Spool")
+ // should NOT trigger the Eager Index Spool warning
+ var plan = PlanTestHelper.LoadAndAnalyze("eager_table_spool_plan.sqlplan");
+ var warnings = PlanTestHelper.WarningsOfType(plan, "Eager Index Spool");
+
+ Assert.Empty(warnings);
+ }
+
// ---------------------------------------------------------------
// Rule 3: Serial Plan
// ---------------------------------------------------------------
diff --git a/tests/PlanViewer.Core.Tests/Plans/eager_table_spool_plan.sqlplan b/tests/PlanViewer.Core.Tests/Plans/eager_table_spool_plan.sqlplan
new file mode 100644
index 0000000..40c0a52
--- /dev/null
+++ b/tests/PlanViewer.Core.Tests/Plans/eager_table_spool_plan.sqlplan
@@ -0,0 +1,1283 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/PlanViewer.Core.Tests/Plans/multi_index_delete_plan.sqlplan b/tests/PlanViewer.Core.Tests/Plans/multi_index_delete_plan.sqlplan
new file mode 100644
index 0000000..7444937
Binary files /dev/null and b/tests/PlanViewer.Core.Tests/Plans/multi_index_delete_plan.sqlplan differ
diff --git a/tests/PlanViewer.Core.Tests/Plans/multi_index_insert_plan.sqlplan b/tests/PlanViewer.Core.Tests/Plans/multi_index_insert_plan.sqlplan
new file mode 100644
index 0000000..6473047
Binary files /dev/null and b/tests/PlanViewer.Core.Tests/Plans/multi_index_insert_plan.sqlplan differ
diff --git a/tests/PlanViewer.Core.Tests/Plans/multi_index_update_plan.sqlplan b/tests/PlanViewer.Core.Tests/Plans/multi_index_update_plan.sqlplan
new file mode 100644
index 0000000..0201133
Binary files /dev/null and b/tests/PlanViewer.Core.Tests/Plans/multi_index_update_plan.sqlplan differ
diff --git a/tests/PlanViewer.Core.Tests/SqlObjectResolverTests.cs b/tests/PlanViewer.Core.Tests/SqlObjectResolverTests.cs
new file mode 100644
index 0000000..adcfa83
--- /dev/null
+++ b/tests/PlanViewer.Core.Tests/SqlObjectResolverTests.cs
@@ -0,0 +1,143 @@
+using PlanViewer.Core.Services;
+
+namespace PlanViewer.Core.Tests;
+
+public class SqlObjectResolverTests
+{
+ [Fact]
+ public void Resolve_TableInSelect_ReturnsTable()
+ {
+ var sql = "SELECT * FROM dbo.Posts";
+ // ^--- offset 14 = start of "dbo.Posts"
+ var offset = sql.IndexOf("dbo.Posts");
+
+ var result = SqlObjectResolver.Resolve(sql, offset);
+
+ Assert.NotNull(result);
+ Assert.Equal("dbo", result.SchemaName);
+ Assert.Equal("Posts", result.ObjectName);
+ Assert.Equal(SqlObjectKind.Table, result.Kind);
+ }
+
+ [Fact]
+ public void Resolve_TableInJoin_ReturnsTable()
+ {
+ var sql = "SELECT p.Id FROM dbo.Posts AS p JOIN dbo.Users AS u ON p.OwnerUserId = u.Id";
+ var offset = sql.IndexOf("dbo.Users");
+
+ var result = SqlObjectResolver.Resolve(sql, offset);
+
+ Assert.NotNull(result);
+ Assert.Equal("Users", result.ObjectName);
+ Assert.Equal(SqlObjectKind.Table, result.Kind);
+ }
+
+ [Fact]
+ public void Resolve_ClickOnObjectName_ReturnsTable()
+ {
+ var sql = "SELECT * FROM dbo.Posts";
+ // Click on "Posts" part specifically
+ var offset = sql.IndexOf("Posts");
+
+ var result = SqlObjectResolver.Resolve(sql, offset);
+
+ Assert.NotNull(result);
+ Assert.Equal("Posts", result.ObjectName);
+ Assert.Equal(SqlObjectKind.Table, result.Kind);
+ }
+
+ [Fact]
+ public void Resolve_UnqualifiedTable_ReturnsEmptySchema()
+ {
+ var sql = "SELECT * FROM Posts";
+ var offset = sql.IndexOf("Posts");
+
+ var result = SqlObjectResolver.Resolve(sql, offset);
+
+ Assert.NotNull(result);
+ Assert.Equal("", result.SchemaName);
+ Assert.Equal("Posts", result.ObjectName);
+ }
+
+ [Fact]
+ public void Resolve_ClickOnKeyword_ReturnsNull()
+ {
+ var sql = "SELECT * FROM dbo.Posts";
+ var offset = sql.IndexOf("SELECT");
+
+ var result = SqlObjectResolver.Resolve(sql, offset);
+
+ Assert.Null(result);
+ }
+
+ [Fact]
+ public void Resolve_Procedure_ReturnsProcedure()
+ {
+ var sql = "EXEC dbo.sp_MyProc @param = 1";
+ var offset = sql.IndexOf("dbo.sp_MyProc");
+
+ var result = SqlObjectResolver.Resolve(sql, offset);
+
+ Assert.NotNull(result);
+ Assert.Equal("sp_MyProc", result.ObjectName);
+ Assert.Equal(SqlObjectKind.Procedure, result.Kind);
+ }
+
+ [Fact]
+ public void Resolve_TableValuedFunction_ReturnsFunction()
+ {
+ var sql = "SELECT * FROM dbo.MyFunction(1, 2)";
+ var offset = sql.IndexOf("dbo.MyFunction");
+
+ var result = SqlObjectResolver.Resolve(sql, offset);
+
+ Assert.NotNull(result);
+ Assert.Equal("MyFunction", result.ObjectName);
+ Assert.Equal(SqlObjectKind.Function, result.Kind);
+ }
+
+ [Fact]
+ public void ResolveAll_MultipleObjects_ReturnsAll()
+ {
+ var sql = @"
+SELECT p.Id, u.DisplayName
+FROM dbo.Posts AS p
+JOIN dbo.Users AS u ON p.OwnerUserId = u.Id
+WHERE p.PostTypeId = 1";
+
+ var results = SqlObjectResolver.ResolveAll(sql);
+
+ Assert.Equal(2, results.Count);
+ Assert.Contains(results, r => r.ObjectName == "Posts");
+ Assert.Contains(results, r => r.ObjectName == "Users");
+ }
+
+ [Fact]
+ public void ResolveAll_DuplicateTable_ReturnsDistinct()
+ {
+ var sql = @"
+SELECT * FROM dbo.Posts
+UNION ALL
+SELECT * FROM dbo.Posts";
+
+ var results = SqlObjectResolver.ResolveAll(sql);
+
+ Assert.Single(results);
+ Assert.Equal("Posts", results[0].ObjectName);
+ }
+
+ [Fact]
+ public void Resolve_EmptyText_ReturnsNull()
+ {
+ Assert.Null(SqlObjectResolver.Resolve("", 0));
+ Assert.Null(SqlObjectResolver.Resolve(" ", 0));
+ }
+
+ [Fact]
+ public void Resolve_InvalidOffset_ReturnsNull()
+ {
+ var sql = "SELECT * FROM dbo.Posts";
+ Assert.Null(SqlObjectResolver.Resolve(sql, -1));
+ Assert.Null(SqlObjectResolver.Resolve(sql, sql.Length));
+ }
+}