WIP query tool, executes query for real and shows result

This commit is contained in:
eelke 2025-10-25 15:48:19 +02:00
parent fd4cb8692d
commit bee0e0915f
9 changed files with 204 additions and 61 deletions

View file

@ -1,7 +1,8 @@
# pgLabII AI Assistant Guidelines # pgLabII AI Assistant Guidelines
## Project Context ## Project Context
This is a .NET 8/C# 13 Avalonia cross-platform application for document management. This is a .NET 9/C# 14 Avalonia cross-platform application for querying and inspecting
postgresql databases. It should also be a good editor for SQL files.
### Architecture Overview ### Architecture Overview
- **Main Project**: pgLabII (Avalonia UI) - **Main Project**: pgLabII (Avalonia UI)
@ -12,16 +13,18 @@ This is a .NET 8/C# 13 Avalonia cross-platform application for document manageme
## Coding Standards ## Coding Standards
### C# Guidelines ### C# Guidelines
- Use C# 13 features and modern .NET patterns - Use C# 14 features and modern .NET patterns
- Prefer primary constructors for dependency injection - Prefer primary constructors for dependency injection
- Use `var` for obvious types, explicit types for clarity - Use `var` for obvious types, explicit types for clarity
- Implement proper async/await patterns for I/O operations - Implement proper async/await patterns for I/O operations
- Use nullable reference types consistently - Use nullable reference types consistently
- Prefer "target-typed new" - Prefer "target-typed new"
- Prefer "list initializer" over new - Prefer "list initializer" over new
- Package versions are managed centrally in Directory.Packages.props
### Avalonia-Specific ### Avalonia-Specific
- Follow MVVM pattern strictly - Follow MVVM pattern strictly
- x:Name does work for making components accessible in code-behind
- ViewModels should inherit from appropriate base classes - ViewModels should inherit from appropriate base classes
- Use ReactiveUI patterns where applicable - Use ReactiveUI patterns where applicable
- Make use of annotations to generate bindable properties - Make use of annotations to generate bindable properties

View file

@ -84,9 +84,9 @@ public class EditHistoryManager : IEditHistoryManager
public void SaveToDatabase() public void SaveToDatabase()
{ {
_db.EditHistory.AddRange(_pendingEdits); // _db.EditHistory.AddRange(_pendingEdits);
_db.SaveChanges(); // _db.SaveChanges();
_pendingEdits.Clear(); // _pendingEdits.Clear();
} }
public IReadOnlyList<EditHistoryEntry> GetHistory() => _pendingEdits.AsReadOnly(); public IReadOnlyList<EditHistoryEntry> GetHistory() => _pendingEdits.AsReadOnly();

View file

@ -1,8 +1,12 @@
using System; using System;
using System.Collections.Generic;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Reactive; using System.Reactive;
using System.Threading.Tasks;
using Npgsql;
using ReactiveUI; using ReactiveUI;
using ReactiveUI.SourceGenerators; using ReactiveUI.SourceGenerators;
using pgLabII.Model;
namespace pgLabII.ViewModels; namespace pgLabII.ViewModels;
@ -18,56 +22,50 @@ public class ColumnInfo
public partial class QueryToolViewModel : ViewModelBase, IViewItem public partial class QueryToolViewModel : ViewModelBase, IViewItem
{ {
private readonly ServerConfigurationEntity? _serverConfig;
// Tab caption // Tab caption
[Reactive] private string _caption = "Query"; [Reactive] private string _caption = "Query";
// SQL text bound to the editor // SQL text bound to the editor
[Reactive] private string _userSql = "SELECT 1 AS one, 'hello' AS text;"; [Reactive] private string _userSql = "SELECT n, 1, 'hello', '2025-4-5'::date FROM generate_series(1, 2000) AS d(n)";
// Summary and status labels // Summary and status labels
[Reactive] private string _resultSummary = "Showing 0 rows"; [Reactive] private string _resultSummary = "Showing 0 rows";
[Reactive] private string _status = "Ready"; [Reactive] private string _status = "Ready";
// Paging flags // Paging flags
[Reactive] private bool _canLoadMore = false; [Reactive] private bool _canLoadMore;
[Reactive] private bool _autoLoadMore = false; [Reactive] private bool _autoLoadMore;
// Rows shown in the DataGrid. For now, simple object rows for AutoGenerateColumns. // Rows shown in the DataGrid. For now, simple object rows for AutoGenerateColumns.
public ObservableCollection<object> Rows { get; } = new(); public ObservableCollection<object> Rows { get; } = new();
public ObservableCollection<ColumnInfo> Columns { get; } = new();
[Reactive] private IReadOnlyList<ColumnInfo> _columns = new List<ColumnInfo>();
// Commands (stubs) // Commands
public ReactiveCommand<Unit, Unit> RunQuery { get; } public ReactiveCommand<Unit, Unit> RunQuery { get; }
public ReactiveCommand<Unit, Unit> LoadMore { get; } public ReactiveCommand<Unit, Unit> LoadMore { get; }
public ReactiveCommand<Unit, Unit> ExportResults { get; } public ReactiveCommand<Unit, Unit> ExportResults { get; }
public ReactiveCommand<Unit, Unit> OpenSqlFile { get; } public ReactiveCommand<Unit, Unit> OpenSqlFile { get; }
public ReactiveCommand<Unit, Unit> SaveSqlFile { get; } public ReactiveCommand<Unit, Unit> SaveSqlFile { get; }
public QueryToolViewModel() public QueryToolViewModel(ServerConfigurationEntity? serverConfig)
{ {
// Create stub commands that only update labels/rows so UI is interactive without real DB work. _serverConfig = serverConfig;
RunQuery = ReactiveCommand.Create(() =>
// Create command that executes actual SQL queries
RunQuery = ReactiveCommand.CreateFromTask(async () =>
{ {
Rows.Clear(); await ExecuteQuery();
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(() => LoadMore = ReactiveCommand.Create(() =>
{ {
// Add more demo rows to see paging UX // Add more demo rows to see paging UX
var baseId = Rows.Count;
for (int i = 1; i <= 3; i++) for (int i = 1; i <= 3; i++)
{ {
Rows.Add(new { id = baseId + i, name = $"User {baseId + i}", active = (baseId + i) % 2 == 0 }); Rows.Add(new RowData(10));
} }
this.RaisePropertyChanged(nameof(Rows)); // Force DataGrid refresh this.RaisePropertyChanged(nameof(Rows)); // Force DataGrid refresh
ResultSummary = $"Showing {Rows.Count} rows"; ResultSummary = $"Showing {Rows.Count} rows";
@ -91,4 +89,117 @@ public partial class QueryToolViewModel : ViewModelBase, IViewItem
Status = "Save SQL file (stub)"; Status = "Save SQL file (stub)";
}); });
} }
private async Task ExecuteQuery()
{
if (_serverConfig == null)
{
Status = "Error: No server configuration selected";
ResultSummary = "Showing 0 rows";
return;
}
if (string.IsNullOrWhiteSpace(UserSql))
{
Status = "Error: SQL query is empty";
ResultSummary = "Showing 0 rows";
return;
}
try
{
Status = "Executing query...";
Rows.Clear();
var connStringBuilder = new NpgsqlConnectionStringBuilder
{
Host = _serverConfig.Host,
Port = _serverConfig.Port,
Database = _serverConfig.InitialDatabase,
Username = _serverConfig.UserName,
Password = _serverConfig.Password,
SslMode = _serverConfig.SslMode,
};
using var connection = new NpgsqlConnection(connStringBuilder.ConnectionString);
await connection.OpenAsync();
using var command = new NpgsqlCommand(UserSql, connection);
using var reader = await command.ExecuteReaderAsync();
// Get column information - build in a temporary list to avoid multiple CollectionChanged events
var schema = reader.GetColumnSchema();
var columnList = new List<ColumnInfo>();
foreach (var column in schema)
{
columnList.Add(new ColumnInfo
{
Name = column.ColumnName ?? "Unknown",
DisplayName = column.ColumnName,
DataType = column.DataType ?? typeof(string),
IsSortable = true,
IsFilterable = true
});
}
// Read rows - also build in a temporary list first
var rowList = new List<object>();
int rowCount = 0;
while (await reader.ReadAsync())
{
var values = new object[reader.FieldCount];
reader.GetValues(values);
// Convert to a dynamic object for the DataGrid
var row = new RowData(reader.FieldCount);
for (int i = 0; i < reader.FieldCount; i++)
{
row.Values[i] = values[i];
}
rowList.Add(row);
rowCount++;
}
// Swap the entire Columns list at once (single property change notification)
Columns = columnList;
// Add all rows at once
Rows.Clear();
foreach (var row in rowList)
{
Rows.Add(row);
}
ResultSummary = $"Showing {rowCount} rows";
Status = "Query executed successfully";
CanLoadMore = false; // TODO: Implement pagination if needed
}
catch (Exception ex)
{
Status = $"Error: {ex.Message}";
ResultSummary = "Showing 0 rows";
Rows.Clear();
Columns = new List<ColumnInfo>();
}
}
}
/// <summary>
/// Dynamic row container for displaying results in the DataGrid
/// </summary>
public class RowData
{
public RowData(int columnCount)
{
Values = new object[columnCount];
}
//public Dictionary<string, object?> Values { get; } = new();
public object[] Values { get; }
public object? this[int idx]
{
get => Values[idx]; //.TryGetValue(key, out var value) ? value : null;
set => Values[idx] = value;
}
} }

View file

@ -20,8 +20,9 @@ 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(() => { ExploreCommand = ReactiveCommand.Create(() =>
var window = new Views.SingleDatabaseWindow(); {
var window = new Views.SingleDatabaseWindow(entity);
window.Show(); window.Show();
}); });
} }

View file

@ -13,11 +13,11 @@ public class ServerListViewModel : ViewModelBase
[ [
new (new() new (new()
{ {
Name = "Local pg15", Name = "pg18",
ColorEnabled = true, ColorEnabled = true,
Host = "localhost", Host = "localhost",
Port = 5434, Port = 5418,
InitialDatabase = "dbname", InitialDatabase = "postgres",
UserName = "postgres", UserName = "postgres",
Password = "admin", Password = "admin",
}) })

View file

@ -1,4 +1,5 @@
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using pgLabII.Model;
namespace pgLabII.ViewModels; namespace pgLabII.ViewModels;
@ -7,9 +8,17 @@ namespace pgLabII.ViewModels;
/// </summary> /// </summary>
public class ViewListViewModel : ViewModelBase public class ViewListViewModel : ViewModelBase
{ {
public ObservableCollection<IViewItem> Views { get; } = [ private readonly ServerConfigurationEntity serverConfig;
new QueryToolViewModel() { Caption = "Abc" },
new QueryToolViewModel() { Caption = "Test" } , public ViewListViewModel(ServerConfigurationEntity serverConfig)
]; {
this.serverConfig = serverConfig;
Views = [
new QueryToolViewModel(serverConfig) { Caption = "Abc" },
new QueryToolViewModel(serverConfig) { Caption = "Test" },
];
}
public ObservableCollection<IViewItem> Views { get; private set; }
} }

View file

@ -7,14 +7,17 @@
mc:Ignorable="d" d:DesignWidth="900" d:DesignHeight="600" mc:Ignorable="d" d:DesignWidth="900" d:DesignHeight="600"
x:Class="pgLabII.Views.QueryToolView" x:Class="pgLabII.Views.QueryToolView"
x:DataType="viewModels:QueryToolViewModel"> x:DataType="viewModels:QueryToolViewModel">
<Grid RowDefinitions="Auto,Auto,*"> <Grid RowDefinitions="Auto,Auto,Auto,*">
<!-- Step 2: Open/Save toolbar --> <!-- Step 2: Open/Save toolbar -->
<StackPanel Grid.Row="0" Orientation="Horizontal" Margin="4"> <StackPanel Grid.Row="0" Orientation="Horizontal" Margin="4">
<Button Content="Open" Command="{Binding OpenSqlFile}" ToolTip.Tip="Open .sql file (Ctrl+O)" /> <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"/> <Button Content="Save" Command="{Binding SaveSqlFile}" ToolTip.Tip="Save .sql file (Ctrl+S)" Margin="4,0,0,0"/>
</StackPanel> </StackPanel>
<!-- Step 2: SQL Editor -->
<controls:CodeEditorView Grid.Row="1"
Text="{Binding UserSql, Mode=TwoWay}" />
<!-- Step 3: Results toolbar --> <!-- Step 3: Results toolbar -->
<StackPanel Grid.Row="1" Orientation="Horizontal" Margin="4"> <StackPanel Grid.Row="2" Orientation="Horizontal" Margin="4">
<Button Content="Run" Command="{Binding RunQuery}" /> <Button Content="Run" Command="{Binding RunQuery}" />
<Button Content="Load more" Command="{Binding LoadMore}" IsEnabled="{Binding CanLoadMore}" Margin="4,0,0,0"/> <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"/> <Button Content="Export..." Command="{Binding ExportResults}" Margin="4,0,0,0"/>
@ -22,18 +25,16 @@
<TextBlock Text="{Binding ResultSummary}" Margin="16,0,0,0" VerticalAlignment="Center"/> <TextBlock Text="{Binding ResultSummary}" Margin="16,0,0,0" VerticalAlignment="Center"/>
<TextBlock Text="{Binding Status}" Margin="8,0,0,0" VerticalAlignment="Center" Foreground="Gray"/> <TextBlock Text="{Binding Status}" Margin="8,0,0,0" VerticalAlignment="Center" Foreground="Gray"/>
</StackPanel> </StackPanel>
<!-- Editor and Results grid with splitter --> <!-- Step 4: Results grid -->
<Grid Grid.Row="2" ColumnDefinitions="*,Auto,*"> <DataGrid Grid.Row="3"
<controls:CodeEditorView Grid.Column="0" Text="{Binding UserSql, Mode=TwoWay}" /> x:Name="ResultsDataGrid"
<GridSplitter Grid.Column="1" Width="6" Background="Gray" ShowsPreview="True" HorizontalAlignment="Stretch" /> ItemsSource="{Binding Rows}"
<DataGrid x:Name="ResultsGrid" IsReadOnly="True"
Grid.Column="2" SelectionMode="Extended"
ItemsSource="{Binding Rows}" CanUserSortColumns="True"
IsReadOnly="True" AutoGenerateColumns="False"
SelectionMode="Extended" Margin="4">
CanUserSortColumns="True" <DataGrid.Columns />
AutoGenerateColumns="False" </DataGrid>
Margin="4" />
</Grid>
</Grid> </Grid>
</UserControl> </UserControl>

View file

@ -1,26 +1,41 @@
using Avalonia; using System.ComponentModel;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Markup.Xaml;
using System.Collections.Specialized;
using pgLabII.ViewModels; using pgLabII.ViewModels;
namespace pgLabII.Views; namespace pgLabII.Views;
public partial class QueryToolView : UserControl public partial class QueryToolView : UserControl
{ {
private QueryToolViewModel? _currentVm;
public QueryToolView() public QueryToolView()
{ {
InitializeComponent(); InitializeComponent();
this.DataContextChanged += (_, __) => WireColumns(); this.DataContextChanged += (_, _) => WireColumns();
WireColumns(); WireColumns();
} }
private void WireColumns() private void WireColumns()
{ {
// Unsubscribe from previous ViewModel if it exists
if (_currentVm != null)
{
_currentVm.PropertyChanged -= OnViewModelPropertyChanged;
}
if (DataContext is QueryToolViewModel vm) if (DataContext is QueryToolViewModel vm)
{ {
var grid = this.FindControl<DataGrid>("ResultsGrid"); _currentVm = vm;
vm.Columns.CollectionChanged += (s, e) => RegenerateColumns(grid, vm); vm.PropertyChanged += OnViewModelPropertyChanged;
RegenerateColumns(this.ResultsDataGrid, vm);
}
}
private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(QueryToolViewModel.Columns) && DataContext is QueryToolViewModel vm)
{
var grid = this.ResultsDataGrid;
RegenerateColumns(grid, vm); RegenerateColumns(grid, vm);
} }
} }
@ -28,15 +43,17 @@ public partial class QueryToolView : UserControl
private void RegenerateColumns(DataGrid grid, QueryToolViewModel vm) private void RegenerateColumns(DataGrid grid, QueryToolViewModel vm)
{ {
grid.Columns.Clear(); grid.Columns.Clear();
foreach (var col in vm.Columns) //foreach (var col in vm.Columns)
for (int i = 0; i < vm.Columns.Count; i++)
{ {
var col = vm.Columns[i];
DataGridColumn gridCol; DataGridColumn gridCol;
if (col.DataType == typeof(bool)) if (col.DataType == typeof(bool))
{ {
gridCol = new DataGridCheckBoxColumn gridCol = new DataGridCheckBoxColumn
{ {
Header = col.DisplayName ?? col.Name, Header = col.DisplayName ?? col.Name,
Binding = new Avalonia.Data.Binding(col.Name) Binding = new Avalonia.Data.Binding($"Values[{i}]")
}; };
} }
else else
@ -44,7 +61,7 @@ public partial class QueryToolView : UserControl
gridCol = new DataGridTextColumn gridCol = new DataGridTextColumn
{ {
Header = col.DisplayName ?? col.Name, Header = col.DisplayName ?? col.Name,
Binding = new Avalonia.Data.Binding(col.Name) Binding = new Avalonia.Data.Binding($"Values[{i}]")
}; };
} }
grid.Columns.Add(gridCol); grid.Columns.Add(gridCol);

View file

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