Skip to content

Feature/query editor sql formater#238

Open
rferraton wants to merge 9 commits intoerikdarlingdata:devfrom
rferraton:Feature/QueryEditor-SQLFormater
Open

Feature/query editor sql formater#238
rferraton wants to merge 9 commits intoerikdarlingdata:devfrom
rferraton:Feature/QueryEditor-SQLFormater

Conversation

@rferraton
Copy link
Copy Markdown
Contributor

What does this PR do?

Add a SQL Formater to the Query Editor with format options

Which component(s) does this affect?

  • Desktop App (PlanViewer.App)
  • Core Library (PlanViewer.Core)
  • CLI Tool (PlanViewer.Cli)
  • SSMS Extension (PlanViewer.Ssms)
  • Tests
  • Documentation

How was this tested?

2026-04-18_00h14_12 2026-04-18_00h14_52

Describe the testing you've done. Include:

  • Plan files tested : query generated with nhibernate : a normal awfull one
  • Platforms tested : Windows

Checklist

  • I have read the contributing guide
  • My code builds with zero warnings (dotnet build -c Debug)
  • All tests pass (dotnet test)
  • I have not introduced any hardcoded credentials or server names

Copy link
Copy Markdown
Owner

@erikdarlingdata erikdarlingdata left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR #238 review — SQL Formatter in Query Editor

What it does

Adds a Format button + Format Options dialog to the Query Editor that runs the editor contents through Microsoft.SqlServer.TransactSql.ScriptDom (new SqlFormattingService), with persisted options in %LOCALAPPDATA%/PerformanceStudio/perfstudio_format_settings.json (new SqlFormatSettingsService). New reflection-driven FormatOptionsWindow exposes every SqlFormatSettings property as a DataGrid row.

Good

  • Base branch is dev — correct.
  • DataGrid style include is already registered in App.axaml, so the new DataGrid renders fine.
  • Settings are versioned via SqlVersion (80–170) with a sensible default.
  • No PlanAnalyzer rule changes, so no PerformanceMonitor Dashboard/Lite sync concerns.
  • No :pointerover selectors; ToggleSwitch :checked /template/ selectors are correctly template-part scoped.
  • Inline brushes in FormatOptionsWindow.axaml are in the light-text / dark-bg range — no contrast regression against the #6B7280-style rejections.

Needs attention (inline comments)

  • Theme brushes not reused: FormatOptionsWindow.axaml hardcodes #1A1D23 / #E4E6EB instead of {DynamicResource BackgroundBrush} / {DynamicResource ForegroundBrush}.
  • Unused usings: System.Collections.Generic + System.Linq in FormatOptionsWindow.axaml.cs; System.Text in SqlFormattingService.cs.
  • Silently swallowed errors: Save_Click catch-all, int.Parse without TryParse, and both SqlFormatSettingsService.Load/Save eat every exception. Corrupt settings or locked app-data = defaults forever, no user feedback.
  • ItemsSource = null rebuild in Revert_Click: symptom-fix; the PropertyChanged events should already refresh bindings if the cell template is right.
  • Format button UX: full-document replace drops caret/selection/folding; tooltip advertises a Ctrl+K, Ctrl+D chord that isn't wired up.
  • Recoverable parse errors: SqlFormattingService.Format bails on any error, even when ScriptDom still returns a usable fragment.
  • KeywordCasing enum parse: case-insensitive Enum.TryParse on unchecked user strings can accept "0" etc. — add Enum.IsDefined or just type the property as the enum.

Test coverage

SqlFormattingService and SqlFormatSettings round-tripping are both non-trivial and live in src/, but nothing was added under tests/PlanViewer.Core.Tests/. These services are pure and easy to unit test (format-then-reparse stability, settings JSON round-trip, version fallback). Worth adding a small suite — note that the services currently live in PlanViewer.App, so you may need a new PlanViewer.App.Tests project or move the services to PlanViewer.Core.

Not flagged

FormatOptionRow : INotifyPropertyChanged reads like MVVM creep at first, but QueryStoreGridControl.QueryStoreRow uses the same pattern for DataGrid row backing types, so this is consistent with existing conventions here.


Generated by Claude Code

Comment thread src/PlanViewer.App/Dialogs/FormatOptionsWindow.axaml
Comment thread src/PlanViewer.App/Dialogs/FormatOptionsWindow.axaml.cs
Comment on lines +61 to +79
try
{
var prop = row.PropertyInfo;
object? value;

if (prop.PropertyType == typeof(bool))
value = row.BoolValue;
else if (prop.PropertyType == typeof(int))
value = int.Parse(row.CurrentValue);
else
value = row.CurrentValue;

prop.SetValue(settings, value);
}
catch
{
// Skip invalid values — keep default
}
}
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two issues in this block:

  1. int.Parse(row.CurrentValue) throws on bad input (empty string, "4.5", typo). Use int.TryParse so you can either keep the default or surface feedback — silently discarding a typed value is bad UX.
  2. The catch { } is an empty swallow. If anything at all fails (reflection error, type mismatch), the user's saved settings just quietly don't contain that field and they have no idea why. At minimum write the exception to Debug.WriteLine / logger, or set a status message on the dialog.

Generated by Claude Code

Comment on lines +94 to +95
OptionsGrid.ItemsSource = null;
OptionsGrid.ItemsSource = _rows;
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ItemsSource = null / reassign dance is a hack to force the grid to refresh. Since CurrentValue and BoolValue setters already raise PropertyChanged, bindings should update themselves. If they don't, the real cause is likely that the CellTemplate TextBlock (read-only state) isn't in an edit cell — which means display shows a stale value until you enter edit mode. Prefer fixing the binding mode/template over rebuilding ItemsSource.


Generated by Claude Code

Comment on lines +2017 to +2033

if (errors != null && errors.Count > 0)
{
SetStatus($"Format: {errors.Count} parse error(s) — {errors[0].Message}");
return;
}

QueryEditor.Document.BeginUpdate();
try
{
QueryEditor.Document.Replace(0, QueryEditor.Document.TextLength, formatted);
}
finally
{
QueryEditor.Document.EndUpdate();
}
SetStatus("Formatted");
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two UX notes:

  1. Replacing the whole document via Replace(0, TextLength, formatted) resets caret position, selection, and collapses AvaloniaEdit's fold state. Consider saving the caret offset (or an anchor) before the replace and restoring it after, clamped to the new length.
  2. The tooltip promises a Ctrl+K, Ctrl+D chord but I don't see that key binding wired up anywhere in this PR. Either add the binding on the control (see QueryEditor.KeyBindings / KeyBinding with KeyGesture) or drop the chord from the tooltip.

Generated by Claude Code

@@ -0,0 +1,71 @@
using System.Collections.Generic;
using System.IO;
using System.Text;
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

using System.Text; is unused — no StringBuilder or Encoding in this file. Drop it.


Generated by Claude Code

Comment on lines +100 to +124
public static SqlFormatSettings Load()
{
try
{
if (!File.Exists(SettingsPath))
return new SqlFormatSettings();

var json = File.ReadAllText(SettingsPath);
return JsonSerializer.Deserialize<SqlFormatSettings>(json, JsonOptions) ?? new SqlFormatSettings();
}
catch
{
return new SqlFormatSettings();
}
}

public static void Save(SqlFormatSettings settings)
{
try
{
Directory.CreateDirectory(SettingsDir);
var json = JsonSerializer.Serialize(settings, JsonOptions);
File.WriteAllText(SettingsPath, json);
}
catch
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both Load() and Save() swallow every exception into catch { }. If the settings file is corrupt, or the local app data folder is not writable (locked by antivirus, network profile, etc.), the user silently gets defaults forever with no clue why. At minimum Debug.WriteLine(ex) the exception; better, bubble a load error up so the dialog can show "using defaults — settings file unreadable".

Also, JsonIgnoreCondition.WhenWritingNull is a no-op here since every property is a non-nullable value type or string — nothing would ever serialize as null. Safe to remove.


Generated by Claude Code

Comment on lines +22 to +29
var parser = GetParser(settings.SqlVersion);

using var reader = new StringReader(sql);
var fragment = parser.Parse(reader, out var errors);

if (errors != null && errors.Count > 0)
return (sql, errors);

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parser.Parse(reader, out var errors) returns a non-null IList<ParseError> even on success (empty list), and T-SQL parsers can also produce a non-null errors list and a usable fragment for recoverable errors. The current logic returns the original text whenever errors is non-empty, which means anything with a minor warning won't format at all. Consider checking errors.Count == 0 || fragment != null and only bailing when the fragment is null / unrecoverable.


Generated by Claude Code

Comment on lines +50 to +56
{
AlignClauseBodies = AlignClauseBodies,
AlignColumnDefinitionFields = AlignColumnDefinitionFields,
AlignSetClauseItem = AlignSetClauseItem,
AsKeywordOnOwnLine = AsKeywordOnOwnLine,
IncludeSemicolons = IncludeSemicolons,
IndentSetClause = IndentSetClause,
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Enum.TryParse<KeywordCasing>(KeywordCasing, true, out var kc) — works, but case-insensitive + arbitrary user input means "oops" (or worse, the string "0") will parse to the first enum value rather than fall through to the fallback. Fix: also check Enum.IsDefined after TryParse, or constrain the settings type to an enum instead of string.


Generated by Claude Code

@erikdarlingdata
Copy link
Copy Markdown
Owner

Second-opinion addendum to the review above

Agreeing with everything in the prior review. A few things it missed that are worth calling out:

Worth adding

  • Reflection property order is not guaranteed. FormatOptionsWindow.axaml.cs:31 iterates typeof(SqlFormatSettings).GetProperties() — the runtime doesn't guarantee declaration order. Row order in the grid can shift between runs. Use an explicit [Display(Order=n)] attribute or a hardcoded ordered list.

  • SqlVersion has the same unchecked-parse problem as KeywordCasing. It's an int with no ChoiceOptions entry (FormatOptionsWindow.axaml.cs:39 only sets choices for KeywordCasing), so it renders as a free textbox. int.Parse in Save_Click will accept any integer, and SqlFormattingService.cs:66–74 silently falls back to the 170 parser via the switch default. Add SqlVersion to the choice list with the supported values (80, 90, 100, 110, 120, 130, 140, 150, 160, 170).

  • Format runs synchronously on the UI thread. QuerySessionControl.axaml.cs:2015 calls SqlFormattingService.Format directly in the click handler. ScriptDom can be slow on large scripts — wrap in Task.Run and await, and disable the Format button during the operation.

  • MIT attribution. SqlFormattingService.cs:8 credits madskristensen/SqlFormatter. If any code was copied (even paraphrased), MIT requires the license notice and copyright be preserved — add to LICENSE or a THIRD-PARTY-NOTICES.md, or rephrase the comment if nothing was copied.

Minor

  • ToggleSwitch uses RenderTransform="scale(0.75)" (FormatOptionsWindow.axaml:23). Render transforms don't affect hit-test bounds in Avalonia — click zones extend past the visible control. Use LayoutTransform or resize the template parts.
  • Close button silently discards unsaved edits — no dirty-state prompt.

Second pass by Claude (in-session).

@rferraton
Copy link
Copy Markdown
Contributor Author

  • Fixed some problems but a new one appears, please wait for correction before merging

…a popup message appears with the syntax error
@rferraton
Copy link
Copy Markdown
Contributor Author

OK fixed :
2026-04-18_10h13_34

@erikdarlingdata
Copy link
Copy Markdown
Owner

Follow-up on the updates — nearly everything from both reviews is addressed cleanly. A few notes:

Not a blocker, but worth a follow-up

The new error dialog in QuerySessionControl.axaml.cs:2033-2034 (and :2052) hardcodes #1A1D23 / #E4E6EB via SolidColorBrush(Color.Parse(...)). This matches an existing pattern elsewhere in the file (lines 979, 1004, 1614, 1773, 1798, 1908, 1959), so it's consistent with the code-behind — but it's the same anti-pattern you just fixed in FormatOptionsWindow.axaml by switching to {DynamicResource BackgroundBrush} / {DynamicResource ForegroundBrush}.

Not asking you to rip out every occurrence in this PR — just flagging that the code-behind theming debt exists and the new dialog adds to it. Could be a separate cleanup PR.

Still outstanding from earlier reviews

  • ToggleSwitch in FormatOptionsWindow.axaml:23 still uses RenderTransform="scale(0.75)" — hit-test bounds extend past the visible control. LayoutTransform would fix it.
  • Close button has no dirty-state prompt — silently discards unsaved edits.
  • Format button tooltip still advertises Ctrl+K, Ctrl+D chord that isn't wired up.

None are blockers. Good work on the async format + error popup — nice call on preserving editor content when parse fails.


Follow-up by Claude (in-session).

…rce("BackgroundBrush")!

2. Close without dirty-state prompt : fixed
3. Tooltip Ctrl+K,Ctrl+D : removed
@rferraton
Copy link
Copy Markdown
Contributor Author

The 0.75 scale works for instance and i don't see any problem with it. Tentatives to fix with LayoutTransform failed, so i let it like it is

@erikdarlingdata
Copy link
Copy Markdown
Owner

Nice work on the fixes — most of the earlier review is addressed. A few small items still outstanding that look worth a follow-up commit before merge:

Theme brush regressions (contradict the "Dynamic brushes" commit):

  • FormatOptionsWindow.axaml:45-46DataGrid still has Background="#1A1D23" Foreground="#E4E6EB". Should be {DynamicResource BackgroundBrush} / {DynamicResource ForegroundBrush}.
  • FormatOptionsWindow.axaml:25,29ToggleSwitch :checked /template/ hardcodes #00D4FF. If there's an accent brush in the theme (e.g. AccentBrush), use it; otherwise at minimum lift this into a static resource so it can be themed later.

Format error handling:

  • SqlFormattingService.Format was updated to return (formattedText, errors) with a non-null fragment specifically so recoverable parse errors don't block formatting. But Format_Click in QuerySessionControl.axaml.cs still treats any non-empty errors list as fatal and shows the error dialog. Consider: if formatted differs meaningfully from sql (fragment was produced), apply it and surface the warnings — rather than refusing to format at all.

Settings persistence feedback:

  • SqlFormatSettingsService.Load/Save now Debug.WriteLine on failure, which is an improvement, but the user still gets silent revert-to-defaults if their settings file is corrupt or the folder is locked. Even a transient status-bar message (SetStatus("Format settings could not be loaded — using defaults")) would help.

Re: the ToggleSwitch RenderTransform=scale(0.75) — fair enough, leaving that as-is is fine. The hit-test overshoot is minor and the visual result is what matters.

Not blocking on tests or selection/fold preservation — happy to ship those as follow-ups.


Follow-up review by Claude (in-session).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants