WIP querytool

This commit is contained in:
eelke 2025-09-06 13:32:51 +02:00
parent d78de23ebc
commit fd4cb8692d
9 changed files with 317 additions and 18 deletions

View file

@ -20,6 +20,7 @@
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets> <IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets> <PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
</PackageReference> </PackageReference>
<PackageReference Include="AvaloniaEdit.TextMate" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

108
pgLabII/QueryToolPlan.md Normal file
View file

@ -0,0 +1,108 @@
# Features
- Is a part of the SingleDatabaseWindow
- It's view should go in QueryToolView.axaml
- Uses mvvm
- AvaloniaEdit should be used as a query editor
## Editor
- Use CodeEditorView
- We want to be able to open and save .sql files with the editor
## Result grid
- Use an Avalonia.Controls.DataGrid
- The columns will change on runtime so it should be able to get the column count captions and data types from the viewmodel
- We want to be able to sort the columns
- We want to be able to filter the rows by defining conditions for the columns
- We want to be able to copy the rows to the clipboard
- We want to be able to customize cell rendering to use different colors for types and also do special things like rendering booleans as green checks and red crosses
- Be aware results may contain many rows, we should make a decision on how to handle this
- We want to be able to save the results to a file
---
# Step-by-step plan to create the Query Tool
1. Add QueryToolView to the UI shell.
- Place the view in pgLabII\Views\QueryToolView.axaml and include it within SingleDatabaseWindow as a child region/panel. Ensure DataContext is set to QueryToolViewModel.
- Confirm MVVM wiring: commands and properties will be bound from the ViewModel.
2. Integrate the SQL editor.
- Embed AvaloniaEdit editor in the top area of QueryToolView.
- Bind editor text to a ViewModel property (e.g., UserSql).
- Provide commands for OpenSqlFile and SaveSqlFile; wire to toolbar/buttons and standard shortcuts (Ctrl+O/Ctrl+S).
- Ensure file filters default to .sql and that encoding/line-endings preserve content when saving.
3. Add a results toolbar for query operations.
- Buttons/controls: Run, Cancel (optional), "Load more", Auto-load on scroll toggle, Export..., and a compact status/summary text (e.g., "Showing X of Y rows").
- Bind to RunQuery, LoadMore, ExportResults, AutoLoadMore, ResultSummary, and Status properties.
4. Add the result grid using Avalonia.Controls.DataGrid.
- Enable row and column virtualization. Keep cell templates lightweight to preserve performance.
- Start with AutoGenerateColumns=true; later switch to explicit columns if custom cell templates per type are needed.
- Bind Items to a read-only observable collection of row objects (e.g., Rows).
- Enable extended selection and clipboard copy.
5. Support dynamic columns and types from the ViewModel.
- Expose a Columns metadata collection (names, data types, display hints) from the ViewModel.
- On first page load, update metadata so the grid can reflect the current querys shape.
- If AutoGenerateColumns is disabled, construct DataGrid columns based on metadata (text, number, date, boolean with check/cross visuals).
6. Sorting model.
- On column header sort request, send sort descriptor(s) to the ViewModel.
- Re-run the query via server-side ORDER BY by wrapping the user SQL as a subquery and applying sort expressions.
- Reset paging when sort changes (reload from page 1).
- Clearly indicate if sorting is client-side (fallback) and only affects loaded rows.
7. Filtering model.
- Provide a simple filter row/panel to define per-column conditions.
- Convert user-entered filters to a filter descriptor list in the ViewModel.
- Prefer server-side WHERE by wrapping the user SQL; reset paging when filters change.
- If server-side wrapping is not possible for a given statement, apply client-side filtering to the currently loaded subset and warn that the filter is partial.
8. Data paging and virtualization (for 100k+ rows).
- Choose a default page size of 1000 rows (range 5002000).
- On RunQuery: clear rows, reset page index, set CanLoadMore=true, fetch page 1.
- "Load more" fetches the next page and appends. Enable infinite scroll optionally when near the end.
- Display summary text: "Showing N of M+ rows" when total is known; otherwise "Showing N rows".
- Consider a cap on retained rows (e.g., last 1020k) if memory is a concern.
9. Query execution abstraction.
- Use a service (e.g., IQueryExecutor) to run database calls.
- Provide: FetchPageAsync(userSql, sort, filters, page, size, ct) and StreamAllAsync(userSql, sort, filters, ct) for export.
- Wrap user SQL as a subquery to inject WHERE/ORDER BY/LIMIT/OFFSET safely; trim trailing semicolons.
- Prefer keyset pagination when a stable ordered key exists.
10. Export/Save results.
- Export should re-execute the query and stream the full result set directly from the database to CSV/TSV/JSON.
- Do not export from the grid items because the grid may contain only a subset of rows.
- Provide a Save As dialog with format choice and destination path.
11. Copy to clipboard and selection.
- Enable extended row selection in the grid; support Ctrl+C to copy selected rows.
- Provide a toolbar "Copy" button as an alternative entry point.
12. Status, cancellation, and errors.
- Show progress/state (Running, Idle, Loading page k, Cancelled, Error).
- Support cancellation tokens for long-running queries and paging operations.
- Surface exceptions as non-blocking notifications and preserve the last successful rows.
13. Theming and custom cell rendering.
- Apply subtle coloring by type (numbers, dates, strings) via cell styles or templates.
- Render booleans as green checks/red crosses with minimal template overhead to keep virtualization effective.
14. Wiring in SingleDatabaseWindow.
- Add a dedicated region/tab/panel for the Query Tool.
- Ensure lifetime management of the QueryToolViewModel aligns with the connection/session scope.
- Provide the active connection context/service to the ViewModel (DI or constructor).
15. Testing and verification.
- Manual test: small query, large query (100k rows), sorting, filtering, load more, infinite scroll, export, copy, boolean rendering.
- Edge cases: empty results, wide tables (many columns), slow network, cancellation mid-page, schema change between pages.
- Performance check: scroll smoothness, memory growth under repeated paging, export throughput.
16. Documentation and UX notes.
- In help/tooltip, clarify that sorting/filtering are server-side when possible; otherwise they apply only to loaded rows.
- Show a banner when results are truncated by paging limits and how to load more.

View file

@ -1,22 +1,94 @@
using System.Reactive; using System;
using System.Collections.ObjectModel;
using System.Reactive;
using ReactiveUI; using ReactiveUI;
using ReactiveUI.SourceGenerators; using ReactiveUI.SourceGenerators;
namespace pgLabII.ViewModels; namespace pgLabII.ViewModels;
public class ColumnInfo
{
public string Name { get; set; } = string.Empty;
public string? DisplayName { get; set; }
public Type DataType { get; set; } = typeof(string);
public bool IsSortable { get; set; } = true;
public bool IsFilterable { get; set; } = true;
// Add more metadata as needed (format, cell template, etc.)
}
public partial class QueryToolViewModel : ViewModelBase, IViewItem public partial class QueryToolViewModel : ViewModelBase, IViewItem
{ {
[Reactive] private string _caption = "Cap"; // Tab caption
[Reactive] private string _caption = "Query";
[Reactive] private string _query = ""; // SQL text bound to the editor
[Reactive] private string _userSql = "SELECT 1 AS one, 'hello' AS text;";
public ReactiveCommand<Unit, Unit> EditCommand { get; } // Summary and status labels
[Reactive] private string _resultSummary = "Showing 0 rows";
[Reactive] private string _status = "Ready";
// Paging flags
[Reactive] private bool _canLoadMore = false;
[Reactive] private bool _autoLoadMore = false;
// Rows shown in the DataGrid. For now, simple object rows for AutoGenerateColumns.
public ObservableCollection<object> Rows { get; } = new();
public ObservableCollection<ColumnInfo> Columns { get; } = new();
// Commands (stubs)
public ReactiveCommand<Unit, Unit> RunQuery { get; }
public ReactiveCommand<Unit, Unit> LoadMore { get; }
public ReactiveCommand<Unit, Unit> ExportResults { get; }
public ReactiveCommand<Unit, Unit> OpenSqlFile { get; }
public ReactiveCommand<Unit, Unit> SaveSqlFile { get; }
public QueryToolViewModel() public QueryToolViewModel()
{ {
EditCommand = ReactiveCommand.Create(() => // Create stub commands that only update labels/rows so UI is interactive without real DB work.
RunQuery = ReactiveCommand.Create(() =>
{ {
Query += " test"; Rows.Clear();
Columns.Clear();
Columns.Add(new ColumnInfo { Name = "id", DataType = typeof(int), DisplayName = "ID" });
Columns.Add(new ColumnInfo { Name = "name", DataType = typeof(string), DisplayName = "Name" });
Columns.Add(new ColumnInfo { Name = "active", DataType = typeof(bool), DisplayName = "Active" });
Rows.Add(new { id = 1, name = "Alice", active = true });
Rows.Add(new { id = 2, name = "Bob", active = false });
this.RaisePropertyChanged(nameof(Rows)); // Force DataGrid refresh
ResultSummary = $"Showing {Rows.Count} rows";
Status = "Query executed (stub)";
CanLoadMore = true;
});
LoadMore = ReactiveCommand.Create(() =>
{
// Add more demo rows to see paging UX
var baseId = Rows.Count;
for (int i = 1; i <= 3; i++)
{
Rows.Add(new { id = baseId + i, name = $"User {baseId + i}", active = (baseId + i) % 2 == 0 });
}
this.RaisePropertyChanged(nameof(Rows)); // Force DataGrid refresh
ResultSummary = $"Showing {Rows.Count} rows";
Status = "Loaded more (stub)";
// Stop after a few loads visually
CanLoadMore = Rows.Count < 12;
});
ExportResults = ReactiveCommand.Create(() =>
{
Status = "Export invoked (stub)";
});
OpenSqlFile = ReactiveCommand.Create(() =>
{
Status = "Open SQL file (stub)";
});
SaveSqlFile = ReactiveCommand.Create(() =>
{
Status = "Save SQL file (stub)";
}); });
} }
} }

View file

@ -20,7 +20,10 @@ public class ServerConfigurationViewModel : ReactiveObject
var window = new Views.EditServerConfigurationWindow(new(this)) { New = false }; var window = new Views.EditServerConfigurationWindow(new(this)) { New = false };
window.Show(); window.Show();
}); });
ExploreCommand = ReactiveCommand.Create(() => { /* window coordination can be injected later */ }); ExploreCommand = ReactiveCommand.Create(() => {
var window = new Views.SingleDatabaseWindow();
window.Show();
});
} }
public ServerConfigurationEntity Entity => _entity; public ServerConfigurationEntity Entity => _entity;

View file

@ -1,9 +1,12 @@
using System.IO; using System.IO;
using Avalonia; using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Data;
using AvaloniaEdit.Document; using AvaloniaEdit.Document;
using AvaloniaEdit.TextMate; using AvaloniaEdit.TextMate;
using TextMateSharp.Grammars; using TextMateSharp.Grammars;
using System;
using System.Reactive.Linq;
namespace pgLabII.Views.Controls; namespace pgLabII.Views.Controls;
@ -18,9 +21,27 @@ public partial class CodeEditorView : UserControl
BaseCopyFilename = "", BaseCopyFilename = "",
}); });
public static readonly AvaloniaProperty<string> TextProperty = AvaloniaProperty.Register<CodeEditorView, string>(
nameof(Text), defaultBindingMode: Avalonia.Data.BindingMode.TwoWay);
public string Text
{
get => Editor?.Text ?? string.Empty;
set
{
if (Editor != null && Editor.Text != value)
Editor.Text = value ?? string.Empty;
}
}
public CodeEditorView() public CodeEditorView()
{ {
InitializeComponent(); InitializeComponent();
this.GetObservable(TextProperty).Subscribe(text =>
{
if (Editor != null && Editor.Text != text)
Editor.Text = text ?? string.Empty;
});
} }
protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
@ -32,8 +53,13 @@ public partial class CodeEditorView : UserControl
_textMate.SetGrammar(registryOptions.GetScopeByLanguageId(registryOptions.GetLanguageByExtension(".sql").Id)); _textMate.SetGrammar(registryOptions.GetScopeByLanguageId(registryOptions.GetLanguageByExtension(".sql").Id));
Editor.Document.Changed += DocumentChanged; Editor.Document.Changed += DocumentChanged;
Editor.TextChanged += Editor_TextChanged;
} }
private void Editor_TextChanged(object? sender, EventArgs e)
{
SetValue(TextProperty, Editor.Text);
}
private void OnSaveClicked(object? sender, Avalonia.Interactivity.RoutedEventArgs e) private void OnSaveClicked(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{ {
@ -41,7 +67,6 @@ public partial class CodeEditorView : UserControl
File.WriteAllText("final.sql", Editor.Text); File.WriteAllText("final.sql", Editor.Text);
} }
private void DocumentChanged(object? sender, DocumentChangeEventArgs e) private void DocumentChanged(object? sender, DocumentChangeEventArgs e)
{ {
_editHistoryManager.AddEdit(e.Offset, e.InsertedText.Text, e.RemovedText.Text); _editHistoryManager.AddEdit(e.Offset, e.InsertedText.Text, e.RemovedText.Text);

View file

@ -0,0 +1,39 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:controls="clr-namespace:pgLabII.Views.Controls"
xmlns:viewModels="clr-namespace:pgLabII.ViewModels"
mc:Ignorable="d" d:DesignWidth="900" d:DesignHeight="600"
x:Class="pgLabII.Views.QueryToolView"
x:DataType="viewModels:QueryToolViewModel">
<Grid RowDefinitions="Auto,Auto,*">
<!-- Step 2: Open/Save toolbar -->
<StackPanel Grid.Row="0" Orientation="Horizontal" Margin="4">
<Button Content="Open" Command="{Binding OpenSqlFile}" ToolTip.Tip="Open .sql file (Ctrl+O)" />
<Button Content="Save" Command="{Binding SaveSqlFile}" ToolTip.Tip="Save .sql file (Ctrl+S)" Margin="4,0,0,0"/>
</StackPanel>
<!-- Step 3: Results toolbar -->
<StackPanel Grid.Row="1" Orientation="Horizontal" Margin="4">
<Button Content="Run" Command="{Binding RunQuery}" />
<Button Content="Load more" Command="{Binding LoadMore}" IsEnabled="{Binding CanLoadMore}" Margin="4,0,0,0"/>
<Button Content="Export..." Command="{Binding ExportResults}" Margin="4,0,0,0"/>
<CheckBox Content="Auto-load on scroll" IsChecked="{Binding AutoLoadMore}" Margin="8,0,0,0"/>
<TextBlock Text="{Binding ResultSummary}" Margin="16,0,0,0" VerticalAlignment="Center"/>
<TextBlock Text="{Binding Status}" Margin="8,0,0,0" VerticalAlignment="Center" Foreground="Gray"/>
</StackPanel>
<!-- Editor and Results grid with splitter -->
<Grid Grid.Row="2" ColumnDefinitions="*,Auto,*">
<controls:CodeEditorView Grid.Column="0" Text="{Binding UserSql, Mode=TwoWay}" />
<GridSplitter Grid.Column="1" Width="6" Background="Gray" ShowsPreview="True" HorizontalAlignment="Stretch" />
<DataGrid x:Name="ResultsGrid"
Grid.Column="2"
ItemsSource="{Binding Rows}"
IsReadOnly="True"
SelectionMode="Extended"
CanUserSortColumns="True"
AutoGenerateColumns="False"
Margin="4" />
</Grid>
</Grid>
</UserControl>

View file

@ -0,0 +1,54 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
using System.Collections.Specialized;
using pgLabII.ViewModels;
namespace pgLabII.Views;
public partial class QueryToolView : UserControl
{
public QueryToolView()
{
InitializeComponent();
this.DataContextChanged += (_, __) => WireColumns();
WireColumns();
}
private void WireColumns()
{
if (DataContext is QueryToolViewModel vm)
{
var grid = this.FindControl<DataGrid>("ResultsGrid");
vm.Columns.CollectionChanged += (s, e) => RegenerateColumns(grid, vm);
RegenerateColumns(grid, vm);
}
}
private void RegenerateColumns(DataGrid grid, QueryToolViewModel vm)
{
grid.Columns.Clear();
foreach (var col in vm.Columns)
{
DataGridColumn gridCol;
if (col.DataType == typeof(bool))
{
gridCol = new DataGridCheckBoxColumn
{
Header = col.DisplayName ?? col.Name,
Binding = new Avalonia.Data.Binding(col.Name)
};
}
else
{
gridCol = new DataGridTextColumn
{
Header = col.DisplayName ?? col.Name,
Binding = new Avalonia.Data.Binding(col.Name)
};
}
grid.Columns.Add(gridCol);
}
}
}

View file

@ -3,6 +3,7 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:viewModels="clr-namespace:pgLabII.ViewModels" xmlns:viewModels="clr-namespace:pgLabII.ViewModels"
xmlns:views="clr-namespace:pgLabII.Views"
xmlns:controls="clr-namespace:pgLabII.Views.Controls" xmlns:controls="clr-namespace:pgLabII.Views.Controls"
xmlns:avaloniaEdit="https://github.com/avaloniaui/avaloniaedit" xmlns:avaloniaEdit="https://github.com/avaloniaui/avaloniaedit"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
@ -47,12 +48,7 @@
<TabControl.ContentTemplate> <TabControl.ContentTemplate>
<DataTemplate DataType="viewModels:QueryToolViewModel"> <DataTemplate DataType="viewModels:QueryToolViewModel">
<DockPanel LastChildFill="True"> <views:QueryToolView />
<TextBox Text="{Binding Query, Mode=TwoWay}" DockPanel.Dock="Top"/>
<Button Command="{Binding EditCommand}" DockPanel.Dock="Top">TEST</Button>
<controls:CodeEditorView />
</DockPanel>
</DataTemplate> </DataTemplate>
</TabControl.ContentTemplate> </TabControl.ContentTemplate>

View file

@ -1,15 +1,16 @@
using Avalonia; using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Markup.Xaml; using Avalonia.Markup.Xaml;
using pgLabII.ViewModels;
namespace pgLabII.Views; namespace pgLabII.Views;
public partial class SingleDatabaseWindow : Window public partial class SingleDatabaseWindow : Window
{ {
public SingleDatabaseWindow() public SingleDatabaseWindow()
{ {
InitializeComponent(); InitializeComponent();
DataContext = new ViewListViewModel();
} }
} }