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
## 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
- **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
### C# Guidelines
- Use C# 13 features and modern .NET patterns
- Use C# 14 features and modern .NET patterns
- Prefer primary constructors for dependency injection
- Use `var` for obvious types, explicit types for clarity
- Implement proper async/await patterns for I/O operations
- Use nullable reference types consistently
- Prefer "target-typed new"
- Prefer "list initializer" over new
- Package versions are managed centrally in Directory.Packages.props
### Avalonia-Specific
- Follow MVVM pattern strictly
- x:Name does work for making components accessible in code-behind
- ViewModels should inherit from appropriate base classes
- Use ReactiveUI patterns where applicable
- Make use of annotations to generate bindable properties

View file

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

View file

@ -1,8 +1,12 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Reactive;
using System.Threading.Tasks;
using Npgsql;
using ReactiveUI;
using ReactiveUI.SourceGenerators;
using pgLabII.Model;
namespace pgLabII.ViewModels;
@ -18,56 +22,50 @@ public class ColumnInfo
public partial class QueryToolViewModel : ViewModelBase, IViewItem
{
private readonly ServerConfigurationEntity? _serverConfig;
// Tab caption
[Reactive] private string _caption = "Query";
// 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
[Reactive] private string _resultSummary = "Showing 0 rows";
[Reactive] private string _status = "Ready";
// Paging flags
[Reactive] private bool _canLoadMore = false;
[Reactive] private bool _autoLoadMore = false;
[Reactive] private bool _canLoadMore;
[Reactive] private bool _autoLoadMore;
// 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)
[Reactive] private IReadOnlyList<ColumnInfo> _columns = new List<ColumnInfo>();
// Commands
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(ServerConfigurationEntity? serverConfig)
{
// Create stub commands that only update labels/rows so UI is interactive without real DB work.
RunQuery = ReactiveCommand.Create(() =>
_serverConfig = serverConfig;
// Create command that executes actual SQL queries
RunQuery = ReactiveCommand.CreateFromTask(async () =>
{
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;
await ExecuteQuery();
});
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 });
Rows.Add(new RowData(10));
}
this.RaisePropertyChanged(nameof(Rows)); // Force DataGrid refresh
ResultSummary = $"Showing {Rows.Count} rows";
@ -91,4 +89,117 @@ public partial class QueryToolViewModel : ViewModelBase, IViewItem
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 };
window.Show();
});
ExploreCommand = ReactiveCommand.Create(() => {
var window = new Views.SingleDatabaseWindow();
ExploreCommand = ReactiveCommand.Create(() =>
{
var window = new Views.SingleDatabaseWindow(entity);
window.Show();
});
}

View file

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

View file

@ -1,4 +1,5 @@
using System.Collections.ObjectModel;
using pgLabII.Model;
namespace pgLabII.ViewModels;
@ -7,9 +8,17 @@ namespace pgLabII.ViewModels;
/// </summary>
public class ViewListViewModel : ViewModelBase
{
public ObservableCollection<IViewItem> Views { get; } = [
new QueryToolViewModel() { Caption = "Abc" },
new QueryToolViewModel() { Caption = "Test" } ,
private readonly ServerConfigurationEntity serverConfig;
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"
x:Class="pgLabII.Views.QueryToolView"
x:DataType="viewModels:QueryToolViewModel">
<Grid RowDefinitions="Auto,Auto,*">
<Grid RowDefinitions="Auto,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 2: SQL Editor -->
<controls:CodeEditorView Grid.Row="1"
Text="{Binding UserSql, Mode=TwoWay}" />
<!-- 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="Load more" Command="{Binding LoadMore}" IsEnabled="{Binding CanLoadMore}" 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 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"
<!-- Step 4: Results grid -->
<DataGrid Grid.Row="3"
x:Name="ResultsDataGrid"
ItemsSource="{Binding Rows}"
IsReadOnly="True"
SelectionMode="Extended"
CanUserSortColumns="True"
AutoGenerateColumns="False"
Margin="4" />
</Grid>
Margin="4">
<DataGrid.Columns />
</DataGrid>
</Grid>
</UserControl>

View file

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

View file

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