Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CITATION.cff
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ authors:
website: "https://erikdarling.com"
repository-code: "https://github.com/erikdarlingdata/PerformanceStudio"
license: MIT
version: "1.21.0"
date-released: "2026-08-21"
version: "1.22.0"
date-released: "2026-08-31"
keywords:
- sql-server
- execution-plan
Expand Down
2 changes: 1 addition & 1 deletion src/Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
Tests and server/ projects are outside src/ and are unaffected.
-->
<PropertyGroup>
<Version>1.21.0</Version>
<Version>1.22.0</Version>
<Authors>Erik Darling</Authors>
<Company>Darling Data LLC</Company>
<Product>Performance Studio</Product>
Expand Down
5 changes: 5 additions & 0 deletions src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ public partial class QuerySessionControl : UserControl
private readonly ICredentialService _credentialService;
private readonly ConnectionStore _connectionStore;

/// <summary>
/// Full path on disk when the query was loaded from, or last saved to, a file.
/// </summary>
public string? SourceFilePath { get; set; }

private ServerConnection? _serverConnection;
private string? _connectionString;
private string? _selectedDatabase;
Expand Down
155 changes: 133 additions & 22 deletions src/PlanViewer.App/MainWindow.FileOps.cs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,97 @@ private async void OpenFile_Click(object? sender, RoutedEventArgs e)
}
}

private async void OpenQuery_Click(object? sender, RoutedEventArgs e)
{
var files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
{
Title = "Open Query",
AllowMultiple = true,
FileTypeFilter = new[]
{
new FilePickerFileType("SQL Scripts")
{
Patterns = new[] { "*.sql" }
},
FilePickerFileTypes.All
}
});

foreach (var file in files)
{
var path = file.TryGetLocalPath();
if (path != null)
LoadSqlFile(path);
}
}

private async void SaveQuery_Click(object? sender, RoutedEventArgs e)
{
await SaveQueryAsync();
}

/// <summary>
/// Writes the active query tab's text to disk. Always prompts, but defaults to the
/// file the query came from so saving back over it is the path of least resistance.
/// Silently does nothing when the active tab is a plan rather than a query - the menu
/// item is reachable from anywhere and there is nothing to save.
/// </summary>
private async Task SaveQueryAsync()
{
if (MainTabControl.SelectedItem is not TabItem { Content: QuerySessionControl session } tab)
return;

var existing = session.SourceFilePath;

var options = new FilePickerSaveOptions
{
Title = "Save Query",
SuggestedFileName = existing != null ? Path.GetFileName(existing) : "Query.sql",
DefaultExtension = "sql",
FileTypeChoices = new[]
{
new FilePickerFileType("SQL Scripts")
{
Patterns = new[] { "*.sql" }
},
FilePickerFileTypes.All
}
};

if (existing != null)
{
var directory = Path.GetDirectoryName(existing);
if (!string.IsNullOrEmpty(directory))
options.SuggestedStartLocation = await StorageProvider.TryGetFolderFromPathAsync(directory);
}

var file = await StorageProvider.SaveFilePickerAsync(options);

var path = file?.TryGetLocalPath();
if (path != null)
SaveQueryToPath(tab, session, path);
}

/// <summary>
/// The half of saving that does not need a human: write the text, remember where it went,
/// and retitle the tab to match. Split out from the picker so it can be tested.
/// </summary>
internal bool SaveQueryToPath(TabItem tab, QuerySessionControl session, string path)
{
try
{
File.WriteAllText(path, session.QueryEditor.Text);
session.SourceFilePath = path;
SetTabLabel(tab, Path.GetFileName(path));
return true;
}
catch (Exception ex)
{
ShowFileError("Error Saving File", $"Failed to save: {Path.GetFileName(path)}", ex.Message);
return false;
}
}

private async void PasteXml_Click(object? sender, RoutedEventArgs e)
{
await PasteXmlAsync();
Expand Down Expand Up @@ -141,7 +232,7 @@ private void OpenFileByExtension(string filePath)
LoadPlanFile(filePath);
}

private void LoadSqlFile(string filePath)
internal void LoadSqlFile(string filePath)
{
try
{
Expand All @@ -151,6 +242,7 @@ private void LoadSqlFile(string filePath)
_queryCounter++;
var session = new QuerySessionControl(_credentialService, _connectionStore);
session.QueryEditor.Text = text;
session.SourceFilePath = filePath;

var tab = CreateTab(fileName, session);
MainTabControl.Items.Add(tab);
Expand All @@ -159,33 +251,52 @@ private void LoadSqlFile(string filePath)
}
catch (Exception ex)
{
var dialog = new Window
ShowFileError("Error Opening File", $"Failed to open: {Path.GetFileName(filePath)}", ex.Message);
}
}

/// <summary>
/// One modal for every file operation that can fail, so opening and saving report
/// trouble the same way.
///
/// <para>Shown ownerless until this window is visible. Both the command-line open and the
/// restore of the previous session's tabs run from the constructor, so a file that is
/// missing or unreadable at startup reaches here before there is anything to be modal
/// over, and ShowDialog against a window that has not been shown throws rather than
/// reporting the problem it was called about.</para>
/// </summary>
private void ShowFileError(string title, string headline, string detail)
{
var dialog = new Window
{
Title = title,
Width = 450,
Height = 200,
WindowStartupLocation = WindowStartupLocation.CenterOwner,
Content = new StackPanel
{
Title = "Error Opening File",
Width = 450,
Height = 200,
WindowStartupLocation = WindowStartupLocation.CenterOwner,
Content = new StackPanel
Margin = new Avalonia.Thickness(20),
Children =
{
Margin = new Avalonia.Thickness(20),
Children =
new TextBlock
{
Text = headline,
FontWeight = FontWeight.Bold,
Margin = new Avalonia.Thickness(0, 0, 0, 10)
},
new TextBlock
{
new TextBlock
{
Text = $"Failed to open: {Path.GetFileName(filePath)}",
FontWeight = FontWeight.Bold,
Margin = new Avalonia.Thickness(0, 0, 0, 10)
},
new TextBlock
{
Text = ex.Message,
TextWrapping = TextWrapping.Wrap
}
Text = detail,
TextWrapping = TextWrapping.Wrap
}
}
};
}
};

if (IsVisible)
dialog.ShowDialog(this);
}
else
dialog.Show();
}

internal void LoadPlanFile(string filePath)
Expand Down
10 changes: 10 additions & 0 deletions src/PlanViewer.App/MainWindow.Tabs.cs
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,16 @@ private void TabContextMenu_Click(object? sender, RoutedEventArgs e)
}
}

/// <summary>
/// Retitles a tab. The header is a StackPanel whose first child carries the text,
/// which is also what StartRename edits.
/// </summary>
private static void SetTabLabel(TabItem tab, string label)
{
if (tab.Header is StackPanel header && header.Children.Count > 0 && header.Children[0] is TextBlock text)
text.Text = label;
}

private static string? GetTabFilePath(TabItem tab)
{
// Plans opened from file are wrapped in a DockPanel with the viewer as the last child
Expand Down
5 changes: 5 additions & 0 deletions src/PlanViewer.App/MainWindow.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@
<MenuItem Header="_File">
<MenuItem Header="New _Query" Click="NewQuery_Click"
InputGesture="Ctrl+N"/>
<MenuItem Header="Open Q_uery..." Click="OpenQuery_Click"
InputGesture="Ctrl+Shift+O"/>
<MenuItem Header="_Save Query..." Click="SaveQuery_Click"
InputGesture="Ctrl+S"/>
<Separator/>
<MenuItem Header="_Open .sqlplan..." Click="OpenFile_Click"
InputGesture="Ctrl+O"/>
<MenuItem Header="_Paste Plan XML" Click="PasteXml_Click"
Expand Down
9 changes: 9 additions & 0 deletions src/PlanViewer.App/MainWindow.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,10 @@ refresh that has to be remembered at sixteen call sites is one that gets forgott
OpenFile_Click(this, new RoutedEventArgs());
e.Handled = true;
break;
case Key.S:
_ = SaveQueryAsync();
e.Handled = true;
break;
case Key.W:
if (MainTabControl.SelectedItem is TabItem selected)
{
Expand All @@ -124,6 +128,11 @@ refresh that has to be remembered at sixteen call sites is one that gets forgott
break;
}
}
else if (e.KeyModifiers == (KeyModifiers.Control | KeyModifiers.Shift) && e.Key == Key.O)
{
OpenQuery_Click(this, new RoutedEventArgs());
e.Handled = true;
}
else if (e.KeyModifiers == (KeyModifiers.Control | KeyModifiers.Shift) && e.Key == Key.Tab)
{
var tabCount = MainTabControl.Items.Count;
Expand Down
2 changes: 1 addition & 1 deletion src/PlanViewer.App/PlanViewer.App.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
<PackageReference Include="ModelContextProtocol" Version="2.2.0" />
<PackageReference Include="ModelContextProtocol.AspNetCore" Version="2.2.0" />
<PackageReference Include="TextMateSharp.Grammars" Version="2.0.4" />
<PackageReference Include="Microsoft.SqlServer.TransactSql.ScriptDom" Version="180.78.1" />
<PackageReference Include="Microsoft.SqlServer.TransactSql.ScriptDom" Version="180.102.0" />
<PackageReference Include="Velopack" Version="1.2.0" />

<!-- Pin SkiaSharp native assets to match SkiaSharp 3.119.0.
Expand Down
11 changes: 5 additions & 6 deletions src/PlanViewer.Core/Output/ResultMapper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,13 @@ internal static AnalysisResult MapCancellable(
SqlServerBuild = plan.Build
};

foreach (var batch in plan.Batches)
/* #455: includes statements nested inside a stored procedure or UDF body. Without this the
summary counted the EXEC and nothing else, so a procedure carrying dozens of statement
plans reported total_statements 1 and max_estimated_cost 0. */
foreach (var stmt in PlanStatements.EnumerateAll(plan))
{
cancellationToken.ThrowIfCancellationRequested();
foreach (var stmt in batch.Statements)
{
cancellationToken.ThrowIfCancellationRequested();
result.Statements.Add(MapStatement(stmt, cancellationToken));
}
result.Statements.Add(MapStatement(stmt, cancellationToken));
}

result.Summary = BuildSummary(result, cancellationToken);
Expand Down
2 changes: 1 addition & 1 deletion src/PlanViewer.Core/PlanViewer.Core.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
<PackageReference Include="Microsoft.Data.SqlClient" Version="7.0.2" />
<PackageReference Include="Meziantou.Framework.Win32.CredentialManager" Version="3.0.1" />
<PackageReference Include="Microsoft.Data.SqlClient.Extensions.Azure" Version="7.0.2" />
<PackageReference Include="Microsoft.SqlServer.TransactSql.ScriptDom" Version="180.78.1" />
<PackageReference Include="Microsoft.SqlServer.TransactSql.ScriptDom" Version="180.102.0" />
</ItemGroup>

<ItemGroup>
Expand Down
7 changes: 4 additions & 3 deletions src/PlanViewer.Core/Services/PlanAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,11 @@ internal static void AnalyzeCancellable(
CancellationToken cancellationToken)
{
var cfg = config ?? AnalyzerConfig.Default;
foreach (var batch in plan.Batches)
{
cancellationToken.ThrowIfCancellationRequested();
foreach (var stmt in batch.Statements)
/* #455: every statement, including the ones inside a stored procedure or UDF body. This
used to walk batch.Statements alone, so an EXEC <procedure> plan analyzed as a single
statement with nothing to say about it. */
foreach (var stmt in PlanStatements.EnumerateAll(plan))
{
cancellationToken.ThrowIfCancellationRequested();
AnalyzeStatement(stmt, cfg, serverMetadata);
Expand Down
68 changes: 68 additions & 0 deletions src/PlanViewer.Core/Services/PlanStatements.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
using System.Collections.Generic;
using PlanViewer.Core.Models;

namespace PlanViewer.Core.Services;

/// <summary>
/// Walks every statement in a plan, including the ones nested inside a stored procedure or a
/// user-defined function (#455).
///
/// <para><b>Why this exists.</b> A plan captured around <c>EXEC &lt;procedure&gt;</c> puts the
/// procedure's statements under a <c>StoredProc</c> element, and the parser has always read them —
/// into <see cref="PlanStatement.StoredProcPlan"/>. Nothing downstream looked there. The analyzer and
/// the result mapper both walked <c>batch.Statements</c> only, so a plan with eighty-seven statement
/// plans inside a procedure analyzed as one statement, zero warnings, zero cost, and exited 0. The
/// output was well-formed and entirely wrong, which is the worst way for this to fail.</para>
///
/// <para>The traversal itself was not missing — <c>PlanOperations.ValidateComplexity</c> has always
/// descended, which is how the complexity limit counted statements the analysis never saw. This puts
/// that same walk in one place so the two cannot disagree again.</para>
/// </summary>
public static class PlanStatements
{
/// <summary>
/// Every statement in the plan, outermost first, each nested body following the statement that
/// owns it.
/// </summary>
public static IEnumerable<PlanStatement> EnumerateAll(ParsedPlan plan)
{
foreach (var batch in plan.Batches)
{
foreach (var statement in EnumerateAll(batch.Statements))
yield return statement;
}
}

/// <summary>
/// <paramref name="statements"/> and everything nested beneath them.
///
/// <para>An explicit stack rather than recursion, because procedure bodies nest — a procedure
/// calling a procedure calling a function — and #430 was a crash caused by assuming a plan's
/// shapes are shallow.</para>
/// </summary>
public static IEnumerable<PlanStatement> EnumerateAll(IReadOnlyList<PlanStatement> statements)
{
var pending = new Stack<PlanStatement>();
for (var i = statements.Count - 1; i >= 0; i--)
pending.Push(statements[i]);

while (pending.TryPop(out var statement))
{
yield return statement;

/* Pushed in reverse so the bodies come back out in source order, and pushed AFTER the
statement is yielded so a body follows the EXEC that owns it rather than preceding it. */
for (var i = statement.UdfPlans.Count - 1; i >= 0; i--)
PushAll(statement.UdfPlans[i].Statements, pending);

if (statement.StoredProcPlan is not null)
PushAll(statement.StoredProcPlan.Statements, pending);
}
}

private static void PushAll(IReadOnlyList<PlanStatement> statements, Stack<PlanStatement> pending)
{
for (var i = statements.Count - 1; i >= 0; i--)
pending.Push(statements[i]);
}
}
Loading
Loading