diff --git a/pgLabII.Desktop/pgLabII.Desktop.csproj b/pgLabII.Desktop/pgLabII.Desktop.csproj index 9002229..e989c75 100644 --- a/pgLabII.Desktop/pgLabII.Desktop.csproj +++ b/pgLabII.Desktop/pgLabII.Desktop.csproj @@ -20,6 +20,7 @@ None All + diff --git a/pgLabII/QueryToolPlan.md b/pgLabII/QueryToolPlan.md new file mode 100644 index 0000000..e94d51c --- /dev/null +++ b/pgLabII/QueryToolPlan.md @@ -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 query’s 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 500–2000). + - 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 10–20k) 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. diff --git a/pgLabII/ViewModels/QueryToolViewModel.cs b/pgLabII/ViewModels/QueryToolViewModel.cs index a2a1854..3f573a3 100644 --- a/pgLabII/ViewModels/QueryToolViewModel.cs +++ b/pgLabII/ViewModels/QueryToolViewModel.cs @@ -1,22 +1,94 @@ -using System.Reactive; +using System; +using System.Collections.ObjectModel; +using System.Reactive; using ReactiveUI; using ReactiveUI.SourceGenerators; 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 { - [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 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 Rows { get; } = new(); + public ObservableCollection Columns { get; } = new(); + + // Commands (stubs) + public ReactiveCommand RunQuery { get; } + public ReactiveCommand LoadMore { get; } + public ReactiveCommand ExportResults { get; } + public ReactiveCommand OpenSqlFile { get; } + public ReactiveCommand SaveSqlFile { get; } 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)"; }); } } diff --git a/pgLabII/ViewModels/ServerConfigurationViewModel.cs b/pgLabII/ViewModels/ServerConfigurationViewModel.cs index 6f640cc..b3d6135 100644 --- a/pgLabII/ViewModels/ServerConfigurationViewModel.cs +++ b/pgLabII/ViewModels/ServerConfigurationViewModel.cs @@ -20,7 +20,10 @@ public class ServerConfigurationViewModel : ReactiveObject var window = new Views.EditServerConfigurationWindow(new(this)) { New = false }; 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; diff --git a/pgLabII/Views/Controls/CodeEditorView.axaml.cs b/pgLabII/Views/Controls/CodeEditorView.axaml.cs index c74919f..c7f8a06 100644 --- a/pgLabII/Views/Controls/CodeEditorView.axaml.cs +++ b/pgLabII/Views/Controls/CodeEditorView.axaml.cs @@ -1,9 +1,12 @@ using System.IO; using Avalonia; using Avalonia.Controls; +using Avalonia.Data; using AvaloniaEdit.Document; using AvaloniaEdit.TextMate; using TextMateSharp.Grammars; +using System; +using System.Reactive.Linq; namespace pgLabII.Views.Controls; @@ -17,12 +20,30 @@ public partial class CodeEditorView : UserControl OriginalFilename = "", BaseCopyFilename = "", }); - + + public static readonly AvaloniaProperty TextProperty = AvaloniaProperty.Register( + 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() { InitializeComponent(); + this.GetObservable(TextProperty).Subscribe(text => + { + if (Editor != null && Editor.Text != text) + Editor.Text = text ?? string.Empty; + }); } - + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) { base.OnAttachedToVisualTree(e); @@ -30,10 +51,15 @@ public partial class CodeEditorView : UserControl var registryOptions = new RegistryOptions(ThemeName.DarkPlus); _textMate = Editor.InstallTextMate(registryOptions); _textMate.SetGrammar(registryOptions.GetScopeByLanguageId(registryOptions.GetLanguageByExtension(".sql").Id)); - + 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) { @@ -41,7 +67,6 @@ public partial class CodeEditorView : UserControl File.WriteAllText("final.sql", Editor.Text); } - private void DocumentChanged(object? sender, DocumentChangeEventArgs e) { _editHistoryManager.AddEdit(e.Offset, e.InsertedText.Text, e.RemovedText.Text); diff --git a/pgLabII/Views/QueryToolView.axaml b/pgLabII/Views/QueryToolView.axaml new file mode 100644 index 0000000..efb51fb --- /dev/null +++ b/pgLabII/Views/QueryToolView.axaml @@ -0,0 +1,39 @@ + + + + + - - - + diff --git a/pgLabII/Views/SingleDatabaseWindow.axaml.cs b/pgLabII/Views/SingleDatabaseWindow.axaml.cs index b4db54a..6525c57 100644 --- a/pgLabII/Views/SingleDatabaseWindow.axaml.cs +++ b/pgLabII/Views/SingleDatabaseWindow.axaml.cs @@ -1,15 +1,16 @@ using Avalonia; using Avalonia.Controls; using Avalonia.Markup.Xaml; +using pgLabII.ViewModels; namespace pgLabII.Views; public partial class SingleDatabaseWindow : Window { - public SingleDatabaseWindow() { InitializeComponent(); + DataContext = new ViewListViewModel(); } }