diff --git a/docs/en/release-notes.md b/docs/en/release-notes.md
index 2799ced..6e98e33 100644
--- a/docs/en/release-notes.md
+++ b/docs/en/release-notes.md
@@ -4,6 +4,12 @@ English | [Português (Brasil)](../pt-BR/release-notes.md)
Fifth public pre-release of PGMS. It is an alpha for evaluation with synthetic/disposable data. Build label: M1053. The binaries are built and attested by the same [release workflow](../../.github/workflows/release.yml) as the previous releases. **License change:** from this release on, original work is under Apache-2.0, with credits in `NOTICE`; releases through alpha.4 remain under MIT.
+## In development (not yet released)
+
+These changes are on `main` and will be part of the next pre-release; the downloads below are still v0.1.0-alpha.5.
+
+- **Query History.** **View ▸ Query History…** (`Ctrl+Alt+H`) lists the SQL executed in query tabs, with server, database, user, start, duration, rows, result and origin (typed, Query Designer, AI, other). Repeated queries collapse into one row with the last run and the count, and expand to each execution. Filter by text, connection and period; reopen in a new query tab without executing, copy, pin or delete. It is stored only on this computer, encrypted with Windows DPAPI for the current Windows user, and never sent to a server. Retention is set in **Tools ▸ Options ▸ Query Execution ▸ Query History** (defaults: 90 days, 10,000 executions, 64 KiB of SQL per execution), and a saved connection profile can turn recording off for its server. SSMS 22.11 has no built-in equivalent. Suggested by a PGMS user.
+
## What changed since v0.1.0-alpha.4
- **Database diagrams.** SSMS-style diagrams saved on the server: add tables and related tables, drag a column to create a foreign key, Arrange Tables, zoom, table views, annotations, new tables and column editing inside the diagram. Database changes are shown as a SQL script first.
diff --git a/docs/en/user-guide.md b/docs/en/user-guide.md
index f1c38a4..8e5b8f8 100644
--- a/docs/en/user-guide.md
+++ b/docs/en/user-guide.md
@@ -59,6 +59,14 @@ Choose compatible PostgreSQL tools and a new output file/directory. Custom, tar,
Only run trusted SQL scripts: psql scripts may include local commands, reconnects and COMMIT. Single-transaction options cannot override those commands. Cancellation can arrive after a commit. Encrypted client keys, CR/LF in tool passwords and non-ASCII TEMP paths have documented restrictions. Keep the source backup unchanged and inspect the destination after failures. The [distribution guide](distribution.md) describes tools, permissions and package requirements.
+## Query History
+
+**View ▸ Query History…** (`Ctrl+Alt+H`) lists the SQL executed in query tabs: `F5`/**Execute** with results to grid, text or file, SQL written by Query Designer or **Generate SQL with AI**, scripts opened by **Script as** and similar tools, and **Execute SQL** inside the Query Designer. Each execution keeps the SQL (up to 64 KiB by default; longer text is cut and marked), server, port, database, user, start time, duration, rows, result (SQLSTATE and message on errors, or canceled) and origin. SQL refused before it is sent, such as text with unbound `$1` placeholders, is not recorded. SSMS 22.11 has no built-in equivalent.
+
+Repeated queries are grouped: one row per distinct SQL on the same server, port and database (runs of spaces and line breaks are ignored, case is not), with the last run and the count. **+** or the right arrow lists each execution. Filter by text, connection and period. **Open in New Query** puts the SQL in a new tab, connected when PGMS already has a connection to that server and user, and never executes it. **Pin** keeps a query on top and its newest execution is never removed by the limits.
+
+**Privacy and storage.** Query History is stored only on this computer, in `%LOCALAPPDATA%\PGMS\history`, encrypted with Windows DPAPI for the current Windows user. It is never written to a server. SQL can hold sensitive values, such as literals or a password typed in `ALTER ROLE … PASSWORD`: anyone signed in as your Windows user can read it in PGMS. Limits and the on/off switch are in **Tools ▸ Options ▸ Query Execution ▸ Query History** (defaults: 90 days, 10,000 executions, 64 KiB per execution); **Clear History…** deletes everything. To record nothing for a server, such as production, check **Do not record query history for this server** in its saved connection profile; it applies to every connection to that host and port. Writing the history never delays or fails a query: a failure only shows a message in the status bar.
+
## Save and recover
Save SQL files explicitly. Background recovery stores bounded encrypted drafts and may reopen them disconnected after interruption. Review recovered tabs and use **Save As**. This is not a backup system or proof that every latest keystroke was persisted. Conflicting external file changes are refused. Close the app before backing up local state; DPAPI is tied to the Windows user. Builds earlier than M934 cannot read the newer recovery container. See [validation](validation.md) for the exact cross-version recovery tested.
diff --git a/docs/pt-BR/release-notes.md b/docs/pt-BR/release-notes.md
index a235807..cac004e 100644
--- a/docs/pt-BR/release-notes.md
+++ b/docs/pt-BR/release-notes.md
@@ -4,6 +4,12 @@
Quinta pré-release pública do PGMS. É uma alpha para avaliação com dados sintéticos ou descartáveis. Rótulo de build: M1053. Os binários são gerados e atestados pelo mesmo [workflow de release](../../.github/workflows/release.yml) das versões anteriores. **Mudança de licença:** a partir desta versão, o trabalho próprio está sob Apache-2.0, com créditos no `NOTICE`; as versões até a alpha.4 continuam sob MIT.
+## Em desenvolvimento (ainda não publicado)
+
+Estas mudanças estão no `main` e farão parte da próxima pré-release; os downloads abaixo continuam sendo da v0.1.0-alpha.5.
+
+- **Histórico de consultas.** **View ▸ Query History…** (`Ctrl+Alt+H`) lista o SQL executado nas abas de consulta, com servidor, banco, usuário, início, duração, linhas, resultado e origem (digitado, Query Designer, IA, outro). Consultas repetidas viram uma linha com a última execução e a contagem, que se expande em cada execução. Filtre por texto, conexão e período; reabra numa aba nova sem executar, copie, fixe ou apague. Fica só neste computador, criptografado com DPAPI do Windows para o usuário Windows atual, e nunca é enviado ao servidor. A retenção fica em **Tools ▸ Options ▸ Query Execution ▸ Query History** (padrões: 90 dias, 10.000 execuções, 64 KiB de SQL por execução), e um perfil de conexão salvo pode desligar a gravação para o servidor dele. O SSMS 22.11 não tem equivalente nativo. Sugestão de um usuário do PGMS.
+
## O que mudou desde a v0.1.0-alpha.4
- **Diagramas de banco.** Diagramas como os do SSMS, salvos no servidor: adicionar tabelas e tabelas relacionadas, arrastar uma coluna para criar chave estrangeira, Arrange Tables, zoom, modos de exibição, anotações, tabelas novas e edição de colunas dentro do diagrama. Alterações no banco aparecem antes como script SQL.
diff --git a/docs/pt-BR/user-guide.md b/docs/pt-BR/user-guide.md
index 6f3bad9..ecec506 100644
--- a/docs/pt-BR/user-guide.md
+++ b/docs/pt-BR/user-guide.md
@@ -59,6 +59,14 @@ Escolha ferramentas PostgreSQL compatíveis e arquivo/diretório de saída novo.
Execute apenas scripts SQL confiáveis: scripts psql podem conter comandos locais, reconexões e COMMIT. Transação única não anula esses comandos. Cancelamento pode chegar após commit. Chaves cliente criptografadas, CR/LF nas senhas das ferramentas e TEMP não ASCII têm restrições documentadas. Preserve o backup original e confira o destino após falhas. O [guia de distribuição](distribution.md) descreve ferramentas, permissões e requisitos do pacote.
+## Histórico de consultas
+
+**View ▸ Query History…** (`Ctrl+Alt+H`) lista o SQL executado nas abas de consulta: `F5`/**Execute** com resultados em grade, texto ou arquivo, SQL escrito pelo Query Designer ou por **Generate SQL with AI**, scripts abertos por **Script as** e ferramentas parecidas, e **Execute SQL** dentro do Query Designer. Cada execução guarda o SQL (até 64 KiB por padrão; texto maior é cortado e marcado), servidor, porta, banco, usuário, início, duração, linhas, resultado (SQLSTATE e mensagem nos erros, ou cancelada) e origem. SQL recusado antes do envio, como texto com placeholders `$1` sem valor, não é gravado. O SSMS 22.11 não tem equivalente nativo.
+
+Consultas repetidas são agrupadas: uma linha por SQL distinto no mesmo servidor, porta e banco (sequências de espaços e quebras de linha são ignoradas; maiúsculas e minúsculas, não), com a última execução e a contagem. **+** ou a seta para a direita lista cada execução. Filtre por texto, conexão e período. **Open in New Query** põe o SQL numa aba nova, conectada quando o PGMS já tem uma conexão com aquele servidor e usuário, e nunca o executa. **Pin** deixa a consulta no topo, e a execução mais recente dela nunca é removida pelos limites.
+
+**Privacidade e armazenamento.** O histórico fica só neste computador, em `%LOCALAPPDATA%\PGMS\history`, criptografado com DPAPI do Windows para o usuário Windows atual. Nunca é gravado no servidor. O SQL pode ter valores sensíveis, como literais ou uma senha digitada em `ALTER ROLE … PASSWORD`: quem entrar com o seu usuário Windows consegue lê-lo no PGMS. Limites e a chave liga/desliga ficam em **Tools ▸ Options ▸ Query Execution ▸ Query History** (padrões: 90 dias, 10.000 execuções, 64 KiB por execução); **Clear History…** apaga tudo. Para não gravar nada de um servidor, como o de produção, marque **Do not record query history for this server** no perfil de conexão salvo; vale para toda conexão àquele host e porta. A gravação nunca atrasa nem faz falhar uma consulta: uma falha só mostra uma mensagem na barra de status.
+
## Salvar e recuperar
Salve arquivos SQL explicitamente. Recuperação em background mantém rascunhos criptografados com limites e pode reabri-los desconectados após interrupção. Revise abas recuperadas e use **Save As**. Isso não é sistema de backup nem prova de persistência de cada última tecla. Alterações externas conflitantes no arquivo são recusadas. Feche o app antes de copiar estado local; DPAPI depende do usuário Windows. Builds anteriores a M934 não leem o contêiner de recuperação mais recente. Veja a [validação](validation.md) para a recuperação entre versões realmente testada.
diff --git a/scripts/Test-Integration.ps1 b/scripts/Test-Integration.ps1
index c551166..94dc5f6 100644
--- a/scripts/Test-Integration.ps1
+++ b/scripts/Test-Integration.ps1
@@ -1,6 +1,6 @@
-param([switch]$ResultsTextOnly, [switch]$TableDesignOnly, [switch]$DiagramsOnly, [switch]$ServerTlsOnly, [switch]$SchemaContextOnly, [switch]$ReportsOnly, [switch]$SessionsOnly, [switch]$PlansOnly, [switch]$ExportOnly, [switch]$ActivityOnly, [switch]$QueryStatisticsOnly, [switch]$GlobalBackupOnly, [switch]$ColumnTypesOnly, [switch]$IndexMaintenanceOnly, [switch]$IndexExpressionsOnly, [switch]$PartitionTablesOnly, [switch]$DesktopOnly, [switch]$CascadeJournalOnly, [switch]$CascadeRoutinesOnly, [switch]$CascadeRollbackOnly, [switch]$RevokePreviewOnly, [switch]$GrantorConcurrencyOnly, [switch]$ExplicitGrantorOnly, [switch]$GrantorCatalogOnly, [switch]$DefaultProcessOnly, [switch]$DefaultDelegationOnly, [switch]$DefaultSchemasOnly, [switch]$DefaultTypesOnly, [switch]$DefaultRoutinesOnly, [switch]$DefaultSequencesOnly, [switch]$DefaultTablesOnly, [switch]$DefaultCancellationOnly, [switch]$DefaultUiOnly, [switch]$DefaultJournalOnly, [switch]$DefaultPrivilegesOnly, [switch]$RoutineBoundariesOnly, [switch]$RoutineProcessOnly, [switch]$RoutineConcurrencyOnly, [switch]$RoutinePrivilegesOnly, [string]$PostgresBin = 'C:\Program Files\PostgreSQL\17\bin', [switch]$BackupFormatsOnly, [switch]$IdentityColumnsOnly, [switch]$RestoreOwnershipOnly, [switch]$UnicodeBackupOnly, [switch]$PrivilegeJournalOnly, [switch]$MaintainPrivilegeOnly, [switch]$PrivilegeCommitOnly, [switch]$UnicodePasswordOnly, [switch]$MembershipOptionsOnly, [switch]$PrivilegeIdentityOnly, [switch]$ColumnPrivilegesOnly, [switch]$PrivilegeCancellationOnly, [switch]$EffectivePrivilegesOnly, [switch]$PrivilegeCommandsOnly, [switch]$SequencePrivilegesOnly, [switch]$CopyOnly, [switch]$AdvancedIndexesOnly, [switch]$SequencesOnly, [switch]$SequenceStateOnly, [switch]$SequenceOwnerOnly, [switch]$AccessibilityOnly, [switch]$ConnectionOnly, [switch]$StreamingOnly, [switch]$ExplorerOnly, [switch]$PropertiesOnly, [switch]$AcceptanceOnly, [ValidateRange(1024,65535)][int]$TestPort = 55439)
+param([switch]$ResultsTextOnly, [switch]$TableDesignOnly, [switch]$DiagramsOnly, [switch]$ServerTlsOnly, [switch]$SchemaContextOnly, [switch]$ReportsOnly, [switch]$SessionsOnly, [switch]$PlansOnly, [switch]$ExportOnly, [switch]$ActivityOnly, [switch]$QueryStatisticsOnly, [switch]$GlobalBackupOnly, [switch]$ColumnTypesOnly, [switch]$IndexMaintenanceOnly, [switch]$IndexExpressionsOnly, [switch]$PartitionTablesOnly, [switch]$DesktopOnly, [switch]$CascadeJournalOnly, [switch]$CascadeRoutinesOnly, [switch]$CascadeRollbackOnly, [switch]$RevokePreviewOnly, [switch]$GrantorConcurrencyOnly, [switch]$ExplicitGrantorOnly, [switch]$GrantorCatalogOnly, [switch]$DefaultProcessOnly, [switch]$DefaultDelegationOnly, [switch]$DefaultSchemasOnly, [switch]$DefaultTypesOnly, [switch]$DefaultRoutinesOnly, [switch]$DefaultSequencesOnly, [switch]$DefaultTablesOnly, [switch]$DefaultCancellationOnly, [switch]$DefaultUiOnly, [switch]$DefaultJournalOnly, [switch]$DefaultPrivilegesOnly, [switch]$RoutineBoundariesOnly, [switch]$RoutineProcessOnly, [switch]$RoutineConcurrencyOnly, [switch]$RoutinePrivilegesOnly, [string]$PostgresBin = 'C:\Program Files\PostgreSQL\17\bin', [switch]$BackupFormatsOnly, [switch]$IdentityColumnsOnly, [switch]$RestoreOwnershipOnly, [switch]$UnicodeBackupOnly, [switch]$PrivilegeJournalOnly, [switch]$MaintainPrivilegeOnly, [switch]$PrivilegeCommitOnly, [switch]$UnicodePasswordOnly, [switch]$MembershipOptionsOnly, [switch]$PrivilegeIdentityOnly, [switch]$ColumnPrivilegesOnly, [switch]$PrivilegeCancellationOnly, [switch]$EffectivePrivilegesOnly, [switch]$PrivilegeCommandsOnly, [switch]$SequencePrivilegesOnly, [switch]$CopyOnly, [switch]$AdvancedIndexesOnly, [switch]$SequencesOnly, [switch]$SequenceStateOnly, [switch]$SequenceOwnerOnly, [switch]$AccessibilityOnly, [switch]$ConnectionOnly, [switch]$StreamingOnly, [switch]$ExplorerOnly, [switch]$PropertiesOnly, [switch]$AcceptanceOnly, [ValidateRange(1024,65535)][int]$TestPort = 55439, [switch]$QueryHistoryOnly)
$ErrorActionPreference = 'Stop'
-if ((@($ResultsTextOnly, $TableDesignOnly, $DiagramsOnly, $SessionsOnly, $PlansOnly, $ExportOnly, $ActivityOnly, $QueryStatisticsOnly, $GlobalBackupOnly, $ColumnTypesOnly, $IndexMaintenanceOnly, $IndexExpressionsOnly, $PartitionTablesOnly, $CascadeJournalOnly, $CascadeRoutinesOnly, $CascadeRollbackOnly, $RevokePreviewOnly, $GrantorConcurrencyOnly, $ExplicitGrantorOnly, $GrantorCatalogOnly, $DefaultProcessOnly, $DefaultDelegationOnly, $DefaultSchemasOnly, $DefaultTypesOnly, $DefaultRoutinesOnly, $DefaultSequencesOnly, $DefaultTablesOnly, $DefaultCancellationOnly, $DefaultUiOnly, $DefaultJournalOnly, $DefaultPrivilegesOnly, $RoutineBoundariesOnly, $RoutineProcessOnly, $RoutineConcurrencyOnly, $RoutinePrivilegesOnly, $BackupFormatsOnly, $IdentityColumnsOnly, $RestoreOwnershipOnly, $UnicodeBackupOnly, $PrivilegeJournalOnly, $MaintainPrivilegeOnly, $PrivilegeCommitOnly, $UnicodePasswordOnly, $ServerTlsOnly, $SchemaContextOnly, $MembershipOptionsOnly, $PrivilegeIdentityOnly, $ColumnPrivilegesOnly, $PrivilegeCancellationOnly, $EffectivePrivilegesOnly, $PrivilegeCommandsOnly, $SequencePrivilegesOnly, $CopyOnly, $AdvancedIndexesOnly, $SequencesOnly, $SequenceStateOnly, $SequenceOwnerOnly, $AccessibilityOnly, $ConnectionOnly, $StreamingOnly, $ExplorerOnly, $ReportsOnly, $PropertiesOnly, $AcceptanceOnly) | Where-Object { $_ }).Count -gt 1) { throw 'Choose only one focused suite.' }
+if ((@($ResultsTextOnly, $TableDesignOnly, $DiagramsOnly, $SessionsOnly, $PlansOnly, $ExportOnly, $ActivityOnly, $QueryStatisticsOnly, $GlobalBackupOnly, $ColumnTypesOnly, $IndexMaintenanceOnly, $IndexExpressionsOnly, $PartitionTablesOnly, $CascadeJournalOnly, $CascadeRoutinesOnly, $CascadeRollbackOnly, $RevokePreviewOnly, $GrantorConcurrencyOnly, $ExplicitGrantorOnly, $GrantorCatalogOnly, $DefaultProcessOnly, $DefaultDelegationOnly, $DefaultSchemasOnly, $DefaultTypesOnly, $DefaultRoutinesOnly, $DefaultSequencesOnly, $DefaultTablesOnly, $DefaultCancellationOnly, $DefaultUiOnly, $DefaultJournalOnly, $DefaultPrivilegesOnly, $RoutineBoundariesOnly, $RoutineProcessOnly, $RoutineConcurrencyOnly, $RoutinePrivilegesOnly, $BackupFormatsOnly, $IdentityColumnsOnly, $RestoreOwnershipOnly, $UnicodeBackupOnly, $PrivilegeJournalOnly, $MaintainPrivilegeOnly, $PrivilegeCommitOnly, $UnicodePasswordOnly, $ServerTlsOnly, $SchemaContextOnly, $MembershipOptionsOnly, $PrivilegeIdentityOnly, $ColumnPrivilegesOnly, $PrivilegeCancellationOnly, $EffectivePrivilegesOnly, $PrivilegeCommandsOnly, $SequencePrivilegesOnly, $CopyOnly, $AdvancedIndexesOnly, $SequencesOnly, $SequenceStateOnly, $SequenceOwnerOnly, $AccessibilityOnly, $ConnectionOnly, $StreamingOnly, $ExplorerOnly, $ReportsOnly, $PropertiesOnly, $AcceptanceOnly, $QueryHistoryOnly) | Where-Object { $_ }).Count -gt 1) { throw 'Choose only one focused suite.' }
if ($DesktopOnly -and ($CascadeRoutinesOnly -or $CascadeRollbackOnly -or $RevokePreviewOnly -or $GrantorConcurrencyOnly -or $ExplicitGrantorOnly -or $GrantorCatalogOnly -or $DefaultDelegationOnly -or $DefaultSchemasOnly -or $DefaultTypesOnly -or $DefaultRoutinesOnly -or $DefaultSequencesOnly -or $DefaultTablesOnly -or $DefaultPrivilegesOnly -or $RoutineConcurrencyOnly -or $PrivilegeIdentityOnly -or $AcceptanceOnly -or $UnicodeBackupOnly)) { throw 'The selected focused suite has no desktop checks.' }
$projectRoot = Split-Path $PSScriptRoot -Parent
$testRoot = Join-Path $projectRoot 'artifacts\postgres-test'
@@ -110,7 +110,8 @@ try {
if ($TableDesignOnly) { $testArgs = @("--", "--table-design") }
if ($DiagramsOnly) { $testArgs = @("--", "--diagrams") }
if ($ResultsTextOnly) { $testArgs = @("--", "--results-text") }
- if (!$CascadeJournalOnly -and !$DefaultProcessOnly -and !$DefaultCancellationOnly -and !$DefaultUiOnly -and !$DefaultJournalOnly -and !$RoutineBoundariesOnly -and !$RoutineProcessOnly -and !$DesktopOnly -and !$PrivilegeJournalOnly -and !$CopyOnly -and !$AccessibilityOnly -and !$ConnectionOnly -and !$ExplorerOnly) {
+ if ($QueryHistoryOnly) { $testArgs = @("--", "--query-history") }
+ if (!$CascadeJournalOnly -and !$DefaultProcessOnly -and !$DefaultCancellationOnly -and !$DefaultUiOnly -and !$DefaultJournalOnly -and !$RoutineBoundariesOnly -and !$RoutineProcessOnly -and !$DesktopOnly -and !$PrivilegeJournalOnly -and !$CopyOnly -and !$AccessibilityOnly -and !$ConnectionOnly -and !$ExplorerOnly -and !$QueryHistoryOnly) {
dotnet run --project tests\PGMS.IntegrationTests\PGMS.IntegrationTests.csproj -c Release @testArgs
if ($LASTEXITCODE -ne 0) { throw 'Integration checks failed' }
}
diff --git a/src/PGMS.Desktop/ConnectionDialog.xaml b/src/PGMS.Desktop/ConnectionDialog.xaml
index 17f03fb..a3e7352 100644
--- a/src/PGMS.Desktop/ConnectionDialog.xaml
+++ b/src/PGMS.Desktop/ConnectionDialog.xaml
@@ -24,7 +24,7 @@
-
+
diff --git a/src/PGMS.Desktop/ConnectionDialog.xaml.cs b/src/PGMS.Desktop/ConnectionDialog.xaml.cs
index 10fc7fb..ba97750 100644
--- a/src/PGMS.Desktop/ConnectionDialog.xaml.cs
+++ b/src/PGMS.Desktop/ConnectionDialog.xaml.cs
@@ -90,7 +90,7 @@ private void ReloadProfiles(Guid? selected = null)
}
private void NewProfile(object sender, RoutedEventArgs e)
{
- Profiles.SelectedItem = null; selectedId = null; ProfileName.Clear(); Password.Clear(); RememberPassword.IsChecked = false; DeleteProfileButton.IsEnabled = false; Error.Text = "New profile. Enter settings and choose Save Profile.";
+ Profiles.SelectedItem = null; selectedId = null; ProfileName.Clear(); Password.Clear(); RememberPassword.IsChecked = false; DisableHistory.IsChecked = false; DeleteProfileButton.IsEnabled = false; Error.Text = "New profile. Enter settings and choose Save Profile.";
}
private void SelectProfile(object sender, SelectionChangedEventArgs e)
{
@@ -98,6 +98,7 @@ private void SelectProfile(object sender, SelectionChangedEventArgs e)
if (rememberChoice) preferences.Update(d => d with { LastProfile = p.Id });
selectedId = p.Id; ProfileName.Text = p.Name; Host.Text = p.Host; Port.Text = p.Port.ToString(); Database.Text = p.Database; Username.Text = p.Username;
Ssl.SelectedItem = Ssl.Items.OfType().First(i => (string)i.Content == p.SslMode); RootCertificate.Text = p.RootCertificate ?? "";
+ DisableHistory.IsChecked = p.DisableQueryHistory;
Password.Clear(); RememberPassword.IsChecked = false; DeleteProfileButton.IsEnabled = true; Error.Text = "";
try { Password.Password = p.RevealPassword(); RememberPassword.IsChecked = p.ProtectedPassword is not null; }
catch (Exception ex) when (IsProfileError(ex)) { Error.Text = "Saved password is unavailable. Re-enter it and save the profile to replace it."; }
@@ -108,7 +109,7 @@ private void SaveProfile(object sender, RoutedEventArgs e)
try
{
string? group = selectedId is { } existing ? store.Load().FirstOrDefault(p => p.Id == existing)?.Group : newProfileGroup;
- var profile = new ConnectionProfile(selectedId ?? Guid.NewGuid(), ProfileName.Text.Trim(), settings.Host!, settings.Port, settings.Database!, settings.Username!, settings.SslMode.ToString(), RootCertificate: string.IsNullOrEmpty(settings.RootCertificate) ? null : settings.RootCertificate, Group: group);
+ var profile = new ConnectionProfile(selectedId ?? Guid.NewGuid(), ProfileName.Text.Trim(), settings.Host!, settings.Port, settings.Database!, settings.Username!, settings.SslMode.ToString(), RootCertificate: string.IsNullOrEmpty(settings.RootCertificate) ? null : settings.RootCertificate, Group: group, DisableQueryHistory: DisableHistory.IsChecked == true);
profile.Validate();
profile = profile.WithPassword(Password.Password, RememberPassword.IsChecked == true);
string sessionPassword = Password.Password;
@@ -158,7 +159,7 @@ public async Task TestConnectionAsync()
}
private void SetTesting(bool testing)
{
- foreach (var control in new Control[] { Profiles, ProfileName, NewProfileButton, Host, Port, Database, Username, Password, Ssl, RootCertificate, BrowseRootButton, RememberPassword, SaveProfileButton, TestButton, ConnectButton })
+ foreach (var control in new Control[] { Profiles, ProfileName, NewProfileButton, Host, Port, Database, Username, Password, Ssl, RootCertificate, BrowseRootButton, RememberPassword, DisableHistory, SaveProfileButton, TestButton, ConnectButton })
control.IsEnabled = !testing;
DeleteProfileButton.IsEnabled = !testing && selectedId is not null;
CancelTestButton.Visibility = testing ? Visibility.Visible : Visibility.Collapsed;
diff --git a/src/PGMS.Desktop/ConnectionProfiles.cs b/src/PGMS.Desktop/ConnectionProfiles.cs
index 9dcb9c0..7925c8a 100644
--- a/src/PGMS.Desktop/ConnectionProfiles.cs
+++ b/src/PGMS.Desktop/ConnectionProfiles.cs
@@ -6,8 +6,9 @@
namespace PGMS.Desktop;
-/// A saved connection. Group is the Registered Servers group path ("Production/Billing"); null or empty is the top level.
-public sealed record ConnectionProfile(Guid Id, string Name, string Host, int Port, string Database, string Username, string SslMode, string? ProtectedPassword = null, string? RootCertificate = null, string? Group = null)
+/// A saved connection. Group is the Registered Servers group path ("Production/Billing"); null or empty is the top level.
+/// DisableQueryHistory turns Query History off for every connection to this host and port (PGMS-043); older files without it record history.
+public sealed record ConnectionProfile(Guid Id, string Name, string Host, int Port, string Database, string Username, string SslMode, string? ProtectedPassword = null, string? RootCertificate = null, string? Group = null, bool DisableQueryHistory = false)
{
public override string ToString() => Name;
public void Validate()
diff --git a/src/PGMS.Desktop/EnvironmentOptions.cs b/src/PGMS.Desktop/EnvironmentOptions.cs
index 6f94a42..650af71 100644
--- a/src/PGMS.Desktop/EnvironmentOptions.cs
+++ b/src/PGMS.Desktop/EnvironmentOptions.cs
@@ -12,8 +12,10 @@ public sealed record WindowPlacement(double Left, double Top, double Width, doub
/// RestoreWindowPlacement starts on, as the user chose; SSMS also reopens where it was closed. SelectTopRows and EditTopRows are the
/// Object Explorer ▸ Commands values for Select Top n Rows and Edit Top n Rows (1000 and 200, as in SSMS); files without them get the defaults.
public sealed record Document(int Version = 1, bool RestoreWindowPlacement = true, WindowPlacement? Window = null, int SelectTopRows = DefaultSelectTopRows, int EditTopRows = DefaultEditTopRows,
- ResultsDestination DefaultResultsDestination = ResultsDestination.Grid, QueryOptions? QueryDefaults = null)
+ ResultsDestination DefaultResultsDestination = ResultsDestination.Grid, QueryOptions? QueryDefaults = null, QueryHistorySettings? QueryHistory = null)
{
+ /// Query Execution ▸ Query History (PGMS-043); files without it record history with the default limits.
+ public QueryHistorySettings History => (QueryHistory ?? new QueryHistorySettings()).Normalized();
/// Query Execution and Query Results pages: what new query tabs start with.
public QueryOptions QueryOptionDefaults => (QueryDefaults ?? new QueryOptions()).Normalized();
/// Query Results ▸ General ▸ Default destination for results of new query tabs; an unknown value falls back to Grid.
diff --git a/src/PGMS.Desktop/MainWindow.QueryDesigner.cs b/src/PGMS.Desktop/MainWindow.QueryDesigner.cs
index cb6a3fa..679dc24 100644
--- a/src/PGMS.Desktop/MainWindow.QueryDesigner.cs
+++ b/src/PGMS.Desktop/MainWindow.QueryDesigner.cs
@@ -29,6 +29,7 @@ internal async Task OpenQueryDesignerAsync()
return;
}
var dialog = new QueryDesignerDialog(connection, selected) { Owner = this };
+ dialog.Designer.ExecutionObserved = query.RecordDesignerExecution;
bool accepted = QueryDesignerHost is { } host ? await host(dialog) : dialog.ShowDialog() == true;
if (!accepted || dialog.ResultSql.Length == 0) return;
string sql = dialog.ResultSql;
diff --git a/src/PGMS.Desktop/MainWindow.QueryHistory.cs b/src/PGMS.Desktop/MainWindow.QueryHistory.cs
new file mode 100644
index 0000000..e599b1e
--- /dev/null
+++ b/src/PGMS.Desktop/MainWindow.QueryHistory.cs
@@ -0,0 +1,67 @@
+using System.IO;
+using System.Windows;
+using Npgsql;
+
+namespace PGMS.Desktop;
+
+// Query History (PGMS-043, docs/MARCO-M1060.md): every execution of a query tab is recorded locally; View ▸ Query History… lists and reopens them.
+public partial class MainWindow
+{
+ private QueryHistoryRecorder? queryHistory;
+ private QueryHistoryWindow? historyWindow;
+ /// Shared by every query tab of this window. Created at startup, which also applies the age and count limits once.
+ internal QueryHistoryRecorder QueryHistory => queryHistory ??= CreateQueryHistory();
+ internal QueryHistoryWindow? OpenHistoryWindow => historyWindow;
+
+ private QueryHistoryRecorder CreateQueryHistory()
+ {
+ var recorder = new QueryHistoryRecorder(new QueryHistoryStore(QueryHistoryStore.DefaultDirectory), () => EnvironmentSettings.Load().History,
+ (server, port) => QueryHistoryRecorder.ProfileExcludes(ConnectionProfileStore.DefaultPath, server, port));
+ recorder.Warning += message => Dispatcher.BeginInvoke(() => Status.Text = message);
+ _ = recorder.PruneAsync();
+ return recorder;
+ }
+
+ private void AttachQueryHistory(QueryView view) => view.HistorySink = capture => _ = QueryHistory.RecordAsync(capture);
+
+ private void ShowQueryHistory(object sender, RoutedEventArgs e) => ShowQueryHistory();
+ /// One window per main window; asking again brings it to the front.
+ internal QueryHistoryWindow ShowQueryHistory()
+ {
+ if (historyWindow is { } open) { open.Activate(); return open; }
+ historyWindow = new QueryHistoryWindow(QueryHistory, async entry => await OpenHistoryEntryAsync(entry)) { Owner = this };
+ historyWindow.Closed += (_, _) => historyWindow = null;
+ historyWindow.Show();
+ return historyWindow;
+ }
+
+ /// The SQL in a new tab, connected when PGMS already has a connection to that server, port and user (its database is switched to the entry's). Never executed.
+ internal async Task OpenHistoryEntryAsync(QueryHistoryEntry entry)
+ {
+ var query = NewQuery(entry.ReopenText, origin: entry.Origin);
+ if (HistoryConnection(entry) is not { } connection)
+ {
+ Status.Text = $"Opened from Query History without a connection: PGMS has no open connection to {entry.Server}:{entry.Port} as {entry.User}. Use Query ▸ Connection ▸ Connect….";
+ return query;
+ }
+ try { await query.ConnectAsync(connection); Status.Text = $"Opened from Query History, connected to {entry.Server}:{entry.Port}/{entry.Database}. Nothing was executed."; }
+ catch (Exception ex) when (ex is NpgsqlException or IOException or OperationCanceledException or InvalidOperationException) { ShowError(ex); }
+ return query;
+ }
+
+ /// An open connection (query tabs, then Object Explorer) to the entry's server, port and user, pointed at its database. The password stays in memory only.
+ internal string? HistoryConnection(QueryHistoryEntry entry)
+ {
+ var candidates = documents.Keys.Select(q => q.ConnectionString).Append(explorerConnection).Append(catalogService?.ConnectionFor(null));
+ foreach (string? candidate in candidates)
+ {
+ if (candidate is null) continue;
+ NpgsqlConnectionStringBuilder c;
+ try { c = new NpgsqlConnectionStringBuilder(candidate); } catch (ArgumentException) { continue; }
+ if (!string.Equals(c.Host, entry.Server, StringComparison.OrdinalIgnoreCase) || c.Port != entry.Port || c.Username != entry.User) continue;
+ c.Database = entry.Database;
+ return c.ConnectionString;
+ }
+ return null;
+ }
+}
diff --git a/src/PGMS.Desktop/MainWindow.Recovery.cs b/src/PGMS.Desktop/MainWindow.Recovery.cs
index e827ad6..711ce4b 100644
--- a/src/PGMS.Desktop/MainWindow.Recovery.cs
+++ b/src/PGMS.Desktop/MainWindow.Recovery.cs
@@ -51,7 +51,7 @@ public RecoveryReport RecoverPendingQueries()
int available = Math.Max(0, QueryRecoveryStore.MaximumDocuments - occupied);
var result = QueryRecoverySession.Recover(Path.Combine(Path.GetDirectoryName(layoutFile)!, "recovery"), recoverySession.Store.DirectoryPath, draft =>
{
- var query = NewQuery(draft.Text);
+ var query = NewQuery(draft.Text, origin: QueryHistoryOrigin.Typed);
query.FileFormat = draft.Format;query.WordWrap=draft.WordWrap;query.ShowWhiteSpace=draft.ShowWhiteSpace;query.CommandTimeoutSeconds=draft.CommandTimeoutSeconds;
query.IsDirty = true;
string title = draft.Title.StartsWith("Recovered - ", StringComparison.Ordinal) ? draft.Title : "Recovered - " + draft.Title;
diff --git a/src/PGMS.Desktop/MainWindow.xaml b/src/PGMS.Desktop/MainWindow.xaml
index 5a537ce..36de37b 100644
--- a/src/PGMS.Desktop/MainWindow.xaml
+++ b/src/PGMS.Desktop/MainWindow.xaml
@@ -35,7 +35,7 @@
-
+
diff --git a/src/PGMS.Desktop/MainWindow.xaml.cs b/src/PGMS.Desktop/MainWindow.xaml.cs
index 8ce4bc7..9e4260b 100644
--- a/src/PGMS.Desktop/MainWindow.xaml.cs
+++ b/src/PGMS.Desktop/MainWindow.xaml.cs
@@ -37,6 +37,7 @@ public MainWindow()
{
InitializeComponent();
InitializeExplorerToolbar();
+ _ = QueryHistory; // startup cleanup of Query History
WindowWorkArea.Attach(this);
// Keep the caption and status reachable on scaled or small work areas.
var workArea = SystemParameters.WorkArea;
@@ -105,12 +106,14 @@ public MainWindow()
// SSMS shortcut for Activity Monitor; with Alt held WPF reports the letter in SystemKey.
if ((e.Key == Key.System ? e.SystemKey : e.Key) == Key.A && Keyboard.Modifiers == (ModifierKeys.Control | ModifierKeys.Alt)) { ShowActivity(this, e); e.Handled = true; }
if ((e.Key == Key.System ? e.SystemKey : e.Key) == Key.G && Keyboard.Modifiers == (ModifierKeys.Control | ModifierKeys.Alt)) { ShowRegisteredServers(); e.Handled = true; }
+ if ((e.Key == Key.System ? e.SystemKey : e.Key) == Key.H && Keyboard.Modifiers == (ModifierKeys.Control | ModifierKeys.Alt)) { ShowQueryHistory(); e.Handled = true; }
};
}
internal Action? UiPhaseMeasured { get; set; }
- private QueryView NewQuery(string? text = null, string? path = null, Guid? recoveryId = null, bool activate = true, PreparedQueryDocument? prepared = null)
+ /// Query History origin of text given here without a file (scripts, generated SQL); recovered drafts pass Typed.
+ private QueryView NewQuery(string? text = null, string? path = null, Guid? recoveryId = null, bool activate = true, PreparedQueryDocument? prepared = null, QueryHistoryOrigin origin = QueryHistoryOrigin.Other)
{
QueryView view;
using (UiPhaseScope.Start(UiPhaseMeasured, "view-total"))
@@ -125,6 +128,8 @@ private QueryView NewQuery(string? text = null, string? path = null, Guid? recov
}
using var dockPhase = UiPhaseScope.Start(UiPhaseMeasured, "dock-and-commands");
view.IsDirty = false;
+ AttachQueryHistory(view);
+ if (prepared is null && path is null && !string.IsNullOrWhiteSpace(text)) view.MarkOrigin(text, origin);
// Tools ▸ Options ▸ Query Results ▸ General ▸ Default destination for results.
var environment = EnvironmentSettings.Load();
view.ResultsDestination = environment.DefaultResults;
@@ -962,7 +967,7 @@ internal void SaveWindowPlacement()
private void ShowOptions(object sender, RoutedEventArgs e)
{
- var dialog = new OptionsDialog(EnvironmentSettings) { Owner = this };
+ var dialog = new OptionsDialog(EnvironmentSettings, () => QueryHistory.ClearAsync()) { Owner = this };
dialog.ShowDialog();
}
diff --git a/src/PGMS.Desktop/OptionsDialog.xaml b/src/PGMS.Desktop/OptionsDialog.xaml
index a50ac67..b603922 100644
--- a/src/PGMS.Desktop/OptionsDialog.xaml
+++ b/src/PGMS.Desktop/OptionsDialog.xaml
@@ -9,6 +9,7 @@
+
@@ -50,6 +51,27 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/PGMS.Desktop/OptionsDialog.xaml.cs b/src/PGMS.Desktop/OptionsDialog.xaml.cs
index 13bdde8..94af8c8 100644
--- a/src/PGMS.Desktop/OptionsDialog.xaml.cs
+++ b/src/PGMS.Desktop/OptionsDialog.xaml.cs
@@ -9,19 +9,46 @@ namespace PGMS.Desktop;
public partial class OptionsDialog : Window
{
private readonly EnvironmentOptions options;
+ private readonly Func>? clearHistory;
- public OptionsDialog(EnvironmentOptions options)
+ /// Clears Query History at once (not on OK) and returns how many executions were removed; null hides the button.
+ public OptionsDialog(EnvironmentOptions options, Func>? clearHistory = null)
{
InitializeComponent();
- this.options = options;
+ this.options = options; this.clearHistory = clearHistory;
var document = options.Load();
RestoreWindowBox.IsChecked = document.RestoreWindowPlacement;
SelectTopBox.Text = document.SelectTop.ToString(CultureInfo.CurrentCulture);
EditTopBox.Text = document.EditTop.ToString(CultureInfo.CurrentCulture);
DefaultResultsBox.SelectedIndex = document.DefaultResults switch { ResultsDestination.Text => 1, ResultsDestination.File => 2, _ => 0 };
QueryPages.Load(document.QueryOptionDefaults);
+ var history = document.History;
+ HistoryEnabledBox.IsChecked = history.Enabled;
+ HistoryDaysBox.Text = history.RetentionDays.ToString(CultureInfo.InvariantCulture);
+ HistoryCountBox.Text = history.MaxExecutions.ToString(CultureInfo.InvariantCulture);
+ HistorySizeBox.Text = history.MaxSqlKiB.ToString(CultureInfo.InvariantCulture);
+ ClearHistoryButton.Visibility = clearHistory is null ? Visibility.Collapsed : Visibility.Visible;
}
+ /// Asks before clearing; tests replace it.
+ internal Func? ConfirmClearHistory { get; set; }
+
+ private async void ClearHistory(object sender, RoutedEventArgs e) => await ClearHistoryAsync();
+ internal async Task ClearHistoryAsync()
+ {
+ if (clearHistory is null) return;
+ bool confirmed = ConfirmClearHistory?.Invoke() ?? MessageBox.Show(this, QueryHistoryWindow.ClearQuestion, "Clear Query History", MessageBoxButton.YesNo, MessageBoxImage.Warning, MessageBoxResult.No) == MessageBoxResult.Yes;
+ if (!confirmed) return;
+ ClearHistoryButton.IsEnabled = false; ClearHistoryStatus.Text = "Clearing…";
+ try { int removed = await clearHistory(); ClearHistoryStatus.Text = $"Query History cleared: {removed:N0} execution(s) removed."; }
+ catch (Exception ex) when (QueryHistoryRecorder.IsDiskError(ex)) { ClearHistoryStatus.Text = "Query History was not cleared. " + ex.Message; }
+ finally { ClearHistoryButton.IsEnabled = true; }
+ }
+
+ /// Whole number within the range, digits only.
+ internal static int? ParseRange(string text, int minimum, int maximum) =>
+ int.TryParse(text.Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out int value) && value >= minimum && value <= maximum ? value : null;
+
private void PageChanged(object sender, RoutedPropertyChangedEventArgs e)
{
if (CommandsPanel is null || QueryResultsPanel is null || QueryPages is null) return; // General is selected in XAML before the panels exist; it is already the visible page
@@ -31,6 +58,7 @@ private void PageChanged(object sender, RoutedPropertyChangedEventArgs e
GeneralPanel.Visibility = page == GeneralPage ? Visibility.Visible : Visibility.Collapsed;
QueryResultsPanel.Visibility = page == QueryResultsPage ? Visibility.Visible : Visibility.Collapsed;
CommandsPanel.Visibility = page == CommandsPage ? Visibility.Visible : Visibility.Collapsed;
+ HistoryPanel.Visibility = page == HistoryPage ? Visibility.Visible : Visibility.Collapsed;
int shared = Array.IndexOf(SharedPages, page);
QueryPages.Visibility = shared >= 0 ? Visibility.Visible : Visibility.Collapsed;
if (shared >= 0) QueryPages.ShowPage(shared);
@@ -58,10 +86,25 @@ internal void Accept(object sender, RoutedEventArgs e)
return;
}
if (QueryPages.TryRead() is not { } queryDefaults) { SharedPages[QueryPages.RejectedPage].IsSelected = true; return; }
+ int? days = ParseRange(HistoryDaysBox.Text, QueryHistorySettings.MinRetentionDays, QueryHistorySettings.MaxRetentionDays);
+ int? count = ParseRange(HistoryCountBox.Text, QueryHistorySettings.MinExecutions, QueryHistorySettings.MaxExecutionsLimit);
+ int? size = ParseRange(HistorySizeBox.Text, QueryHistorySettings.MinSqlKiB, QueryHistorySettings.MaxSqlKiBLimit);
+ if (days is null || count is null || size is null)
+ {
+ var (wrong, message) = days is null ? (HistoryDaysBox, $"Enter a whole number of days from {QueryHistorySettings.MinRetentionDays} to {QueryHistorySettings.MaxRetentionDays:N0}.")
+ : count is null ? (HistoryCountBox, $"Enter a whole number of executions from {QueryHistorySettings.MinExecutions} to {QueryHistorySettings.MaxExecutionsLimit:N0}.")
+ : (HistorySizeBox, $"Enter a whole number of KiB from {QueryHistorySettings.MinSqlKiB} to {QueryHistorySettings.MaxSqlKiBLimit}.");
+ HistoryError.Text = message; HistoryError.Visibility = Visibility.Visible;
+ HistoryPage.IsSelected = true;
+ System.Windows.Input.FocusManager.SetFocusedElement(this, wrong);
+ wrong.Focus(); wrong.SelectAll();
+ return;
+ }
+ var historySettings = new QueryHistorySettings(HistoryEnabledBox.IsChecked == true, days.Value, count.Value, size.Value);
bool restore = RestoreWindowBox.IsChecked == true;
// Turning the option off also forgets the saved size and position.
var destination = DefaultResultsBox.SelectedIndex switch { 1 => ResultsDestination.Text, 2 => ResultsDestination.File, _ => ResultsDestination.Grid };
- options.Update(d => d with { RestoreWindowPlacement = restore, Window = restore ? d.Window : null, SelectTopRows = selectTop.Value, EditTopRows = editTop.Value, DefaultResultsDestination = destination, QueryDefaults = queryDefaults });
+ options.Update(d => d with { RestoreWindowPlacement = restore, Window = restore ? d.Window : null, SelectTopRows = selectTop.Value, EditTopRows = editTop.Value, DefaultResultsDestination = destination, QueryDefaults = queryDefaults, QueryHistory = historySettings });
try { DialogResult = true; }
catch (InvalidOperationException) { Close(); } // shown without ShowDialog
}
diff --git a/src/PGMS.Desktop/QueryDesignerControl.cs b/src/PGMS.Desktop/QueryDesignerControl.cs
index 61ff52c..56e3bc9 100644
--- a/src/PGMS.Desktop/QueryDesignerControl.cs
+++ b/src/PGMS.Desktop/QueryDesignerControl.cs
@@ -921,9 +921,12 @@ public async Task ExecuteAsync()
await LeaveSqlFirstAsync();
using var cancellation = operation = new CancellationTokenSource();
cancelButton.Visibility = Visibility.Visible; resultStatus.Text = "Executing…";
+ string sql = sqlEditor.Text; DateTime startedUtc = DateTime.UtcNow; var timer = System.Diagnostics.Stopwatch.StartNew();
+ Exception? failure = null; long? rows = null;
try
{
- var result = await service.ExecuteAsync(sqlEditor.Text, cancellation.Token);
+ var result = await service.ExecuteAsync(sql, cancellation.Token);
+ rows = result.Table.Rows.Count;
results.Columns.Clear();
foreach (System.Data.DataColumn column in result.Table.Columns)
results.Columns.Add(new DataGridTextColumn { Header = column.Caption, CellStyle = ResultGridStyle.NullAwareCell($"[{column.ColumnName}]"), Binding = new Binding($"[{column.ColumnName}]") { Converter = new DatabaseValueConverter() } });
@@ -931,10 +934,16 @@ public async Task ExecuteAsync()
resultStatus.Text = $"{result.Table.Rows.Count} row(s)" + (result.Truncated ? $"; only the first {QueryDesignService.MaxRows} are shown." : "");
changedMark.Visibility = Visibility.Collapsed;
}
- catch (OperationCanceledException) { resultStatus.Text = "Canceled."; }
- catch (Exception ex) when (ex is NpgsqlException or IOException or TimeoutException or ArgumentException or InvalidOperationException) { resultStatus.Text = Message(ex); }
- finally { operation = null; cancelButton.Visibility = Visibility.Collapsed; }
+ catch (OperationCanceledException ex) { failure = ex; resultStatus.Text = "Canceled."; }
+ catch (Exception ex) when (ex is NpgsqlException or IOException or TimeoutException or ArgumentException or InvalidOperationException) { failure = ex; resultStatus.Text = Message(ex); }
+ finally
+ {
+ operation = null; cancelButton.Visibility = Visibility.Collapsed;
+ if (sql.Trim().Length > 0) ExecutionObserved?.Invoke(sql, startedUtc, timer.Elapsed, failure, failure is OperationCanceledException || cancellation.IsCancellationRequested, rows);
+ }
}
+ /// Each Execute SQL after it ends (SQL, start in UTC, duration, error, canceled, rows shown); the query tab hands it to Query History (PGMS-043).
+ internal Action? ExecutionObserved { get; set; }
/// Verify SQL Syntax against the server; nothing runs.
public async Task VerifyAsync()
diff --git a/src/PGMS.Desktop/QueryHistory.cs b/src/PGMS.Desktop/QueryHistory.cs
new file mode 100644
index 0000000..fb3f7cf
--- /dev/null
+++ b/src/PGMS.Desktop/QueryHistory.cs
@@ -0,0 +1,146 @@
+using System.Security.Cryptography;
+using System.Text;
+using Npgsql;
+
+namespace PGMS.Desktop;
+
+/// How an execution ended. Stored by number: keep the values.
+public enum QueryHistoryOutcome { Succeeded = 0, Failed = 1, Canceled = 2 }
+
+/// Who wrote the SQL that ran. Text inserted by a tool keeps that origin while it is executed unchanged; once edited it counts as typed.
+/// Stored by number: keep the values.
+public enum QueryHistoryOrigin { Typed = 0, QueryDesigner = 1, Ai = 2, Other = 3 }
+
+/// A distinct query: same normalized SQL on the same server, port and database (PGMS-043).
+public sealed record QueryHistoryKey(string Server, int Port, string Database, string Hash);
+
+/// One execution kept in Query History (PGMS-043). Sql may be cut at the size limit: OriginalLength is the length that ran, Hash is of the whole text.
+/// RowsReturned counts the rows the tab received (shown, or read and discarded); RowsAffected is what the server reported for INSERT, UPDATE, DELETE and similar.
+public sealed record QueryHistoryEntry(Guid Id, DateTime StartedUtc, long DurationMilliseconds, string Server, int Port, string Database, string User,
+ string Sql, int OriginalLength, string Hash, QueryHistoryOrigin Origin, QueryHistoryOutcome Outcome,
+ long? RowsReturned = null, long? RowsAffected = null, string? SqlState = null, string? Error = null)
+{
+ public const int MaximumErrorCharacters = 4096, MaximumNameCharacters = 1024;
+ public bool Truncated => Sql.Length < OriginalLength;
+ public QueryHistoryKey Key => new(Server, Port, Database, Hash);
+ /// Text to put back in an editor: a cut entry ends with a comment that says so.
+ public string ReopenText => Truncated
+ ? Sql + Environment.NewLine + $"-- PGMS Query History: this text was cut at {Sql.Length:N0} of {OriginalLength:N0} characters when it was recorded."
+ : Sql;
+
+ internal void Validate()
+ {
+ static bool Name(string? value, bool allowEmpty = false) => value is not null && value.Length <= MaximumNameCharacters && (allowEmpty || value.Length > 0);
+ if (Id == Guid.Empty || StartedUtc.Kind != DateTimeKind.Utc || DurationMilliseconds < 0 || !Name(Server) || Port is < 1 or > 65535 || !Name(Database) || !Name(User, true) ||
+ Sql is null || Sql.Length > QueryHistorySettings.MaxSqlKiBLimit * 1024 || OriginalLength < Sql.Length || Hash is not { Length: 64 } || !Hash.All(Uri.IsHexDigit) ||
+ !Enum.IsDefined(Origin) || !Enum.IsDefined(Outcome) || RowsReturned < 0 || RowsAffected < 0 || SqlState is { Length: > 5 } || Error is { Length: > MaximumErrorCharacters })
+ throw new System.IO.InvalidDataException("Query History holds an invalid entry.");
+ }
+}
+
+/// Tools ▸ Options ▸ Query Execution ▸ Query History. Applies to every connection; a saved profile can turn recording off for its server.
+public sealed record QueryHistorySettings(bool Enabled = true, int RetentionDays = QueryHistorySettings.DefaultRetentionDays, int MaxExecutions = QueryHistorySettings.DefaultMaxExecutions, int MaxSqlKiB = QueryHistorySettings.DefaultMaxSqlKiB)
+{
+ public const int DefaultRetentionDays = 90, MinRetentionDays = 1, MaxRetentionDays = 3650;
+ public const int DefaultMaxExecutions = 10000, MinExecutions = 100, MaxExecutionsLimit = 1000000;
+ public const int DefaultMaxSqlKiB = 64, MinSqlKiB = 1, MaxSqlKiBLimit = 256;
+ /// Size limit of the SQL kept per execution, in UTF-16 characters (1 KiB = 1024 characters).
+ public int MaxSqlCharacters => MaxSqlKiB * 1024;
+ /// Hand-edited files can hold anything: out-of-range values go back to their defaults.
+ public QueryHistorySettings Normalized() => this with
+ {
+ RetentionDays = RetentionDays is >= MinRetentionDays and <= MaxRetentionDays ? RetentionDays : DefaultRetentionDays,
+ MaxExecutions = MaxExecutions is >= MinExecutions and <= MaxExecutionsLimit ? MaxExecutions : DefaultMaxExecutions,
+ MaxSqlKiB = MaxSqlKiB is >= MinSqlKiB and <= MaxSqlKiBLimit ? MaxSqlKiB : DefaultMaxSqlKiB
+ };
+}
+
+/// What a query tab reports after an execution, before the history applies its limits.
+public sealed record QueryHistoryCapture(string Sql, string Server, int Port, string Database, string User, DateTime StartedUtc, TimeSpan Elapsed,
+ QueryHistoryOrigin Origin, QueryHistoryOutcome Outcome, long? RowsReturned = null, long? RowsAffected = null, string? SqlState = null, string? Error = null)
+{
+ /// Server, port and user come from the connection; database is the session's when known. The password is never read.
+ public static QueryHistoryCapture FromConnection(string connectionString, string? database, string sql, DateTime startedUtc, TimeSpan elapsed, QueryHistoryOrigin origin,
+ QueryHistoryOutcome outcome, long? rowsReturned, long? rowsAffected, Exception? error)
+ {
+ var c = new NpgsqlConnectionStringBuilder(connectionString);
+ string? state = (error as PostgresException)?.SqlState;
+ string? message = error is null ? null : error is PostgresException pg ? pg.MessageText : error is OperationCanceledException ? "Canceled." : error.Message;
+ return new(sql, c.Host ?? "", c.Port, string.IsNullOrEmpty(database) ? c.Database ?? "" : database, c.Username ?? "", startedUtc, elapsed, origin, outcome,
+ rowsReturned is < 0 ? null : rowsReturned, rowsAffected is < 0 ? null : rowsAffected, state, message);
+ }
+
+ internal QueryHistoryEntry ToEntry(QueryHistorySettings settings)
+ {
+ string full = QueryHistoryText.Sanitize(Sql);
+ string kept = QueryHistoryText.Cut(full, settings.Normalized().MaxSqlCharacters);
+ string? error = Error is null ? null : QueryHistoryText.Cut(QueryHistoryText.Sanitize(Error), QueryHistoryEntry.MaximumErrorCharacters);
+ static string Name(string value) => QueryHistoryText.Cut(QueryHistoryText.Sanitize(value), QueryHistoryEntry.MaximumNameCharacters);
+ var entry = new QueryHistoryEntry(Guid.NewGuid(), DateTime.SpecifyKind(StartedUtc.ToUniversalTime(), DateTimeKind.Utc), Math.Max(0, (long)Elapsed.TotalMilliseconds),
+ Name(Server.ToLowerInvariant()), Port, Name(Database), Name(User), kept, full.Length, QueryHistoryText.HashOf(full), Origin, Outcome,
+ RowsReturned, RowsAffected, SqlState is { Length: <= 5 } ? SqlState : null, error);
+ entry.Validate();
+ return entry;
+ }
+}
+
+/// Text rules of Query History.
+public static class QueryHistoryText
+{
+ /// Whitespace runs (spaces, tabs, line breaks) become one space and the ends are trimmed; case and everything else are kept.
+ /// Only used to group executions, so a space inside a string literal also counts as one.
+ public static string Normalize(string sql)
+ {
+ var text = new StringBuilder(sql.Length);
+ bool space = false;
+ foreach (char c in sql)
+ {
+ if (char.IsWhiteSpace(c)) { space = text.Length > 0; continue; }
+ if (space) { text.Append(' '); space = false; }
+ text.Append(c);
+ }
+ return text.ToString();
+ }
+ public static string HashOf(string sql) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(Normalize(sql))));
+ /// Lone surrogates cannot be stored as UTF-8; they become U+FFFD.
+ public static string Sanitize(string text)
+ {
+ StringBuilder? copy = null;
+ for (int i = 0; i < text.Length; i++)
+ {
+ char c = text[i];
+ bool lone = char.IsHighSurrogate(c) ? i + 1 >= text.Length || !char.IsLowSurrogate(text[i + 1]) : char.IsLowSurrogate(c) && (i == 0 || !char.IsHighSurrogate(text[i - 1]));
+ if (lone) { copy ??= new StringBuilder(text, 0, i, text.Length); copy.Append('�'); }
+ else copy?.Append(c);
+ }
+ return copy?.ToString() ?? text;
+ }
+ /// At most the given characters, without splitting a surrogate pair.
+ public static string Cut(string text, int maximum)
+ {
+ if (text.Length <= maximum) return text;
+ int length = maximum;
+ if (length > 0 && char.IsHighSurrogate(text[length - 1])) length--;
+ return text[..length];
+ }
+ /// First line of the SQL, for the grid.
+ public static string Preview(string sql, int maximum = 200)
+ {
+ string flat = Normalize(sql);
+ return flat.Length <= maximum ? flat : Cut(flat, maximum) + "…";
+ }
+}
+
+/// One row per distinct query in the Query History window, newest execution first.
+public sealed class QueryHistoryGroup(QueryHistoryKey key, IReadOnlyList executions, bool pinned)
+{
+ public QueryHistoryKey Key { get; } = key;
+ public IReadOnlyList Executions { get; } = executions;
+ public QueryHistoryEntry Latest => Executions[0];
+ public bool Pinned { get; } = pinned;
+
+ public static IReadOnlyList Build(QueryHistorySnapshot snapshot) =>
+ snapshot.Entries.GroupBy(e => e.Key)
+ .Select(g => new QueryHistoryGroup(g.Key, g.OrderByDescending(e => e.StartedUtc).ThenByDescending(e => e.Id).ToArray(), snapshot.Pins.Contains(g.Key)))
+ .OrderByDescending(g => g.Pinned).ThenByDescending(g => g.Latest.StartedUtc).ToArray();
+}
diff --git a/src/PGMS.Desktop/QueryHistoryRecorder.cs b/src/PGMS.Desktop/QueryHistoryRecorder.cs
new file mode 100644
index 0000000..88201fa
--- /dev/null
+++ b/src/PGMS.Desktop/QueryHistoryRecorder.cs
@@ -0,0 +1,75 @@
+using System.IO;
+using System.Security.Cryptography;
+using System.Text.Json;
+
+namespace PGMS.Desktop;
+
+/// Writes executions to Query History on its own queue (PGMS-043). Recording never blocks or fails a query: the tab hands over what ran and returns;
+/// a failure only raises Warning. Settings and the profile exclusions are read on the queue, at the moment of each write.
+public sealed class QueryHistoryRecorder
+{
+ private readonly Func settings;
+ private readonly Func excluded;
+ private readonly Func clock;
+ public QueryHistoryStore Store { get; }
+ /// Serializes every disk operation of this history; the window reads through it too.
+ public RecoveryIoQueue Io { get; } = new();
+ /// Raised on a worker thread with a message for the status bar.
+ public event Action? Warning;
+ /// Raised on a worker thread after an execution was written.
+ public event Action? Recorded;
+
+ /// True when recording is turned off for that server and port; an exception counts as excluded, so a profile file that cannot be read never lets SQL through.
+ public QueryHistoryRecorder(QueryHistoryStore store, Func settings, Func excluded, Func? clock = null)
+ {
+ Store = store; this.settings = settings; this.excluded = excluded; this.clock = clock ?? (() => DateTime.UtcNow);
+ }
+
+ /// Queues the execution. The task says whether it was written; callers never wait for it.
+ public Task RecordAsync(QueryHistoryCapture capture) => Io.RunAsync(() =>
+ {
+ try
+ {
+ var current = settings().Normalized();
+ if (!current.Enabled) return false;
+ bool skip;
+ try { skip = excluded(capture.Server, capture.Port); }
+ catch (Exception ex) when (IsDiskError(ex))
+ {
+ Warn("Query History did not record this execution: saved connection profiles could not be read to check whether this server is excluded. " + ex.Message);
+ return false;
+ }
+ if (skip) return false;
+ var entry = capture.ToEntry(current);
+ Store.Append(entry, current, clock());
+ Recorded?.Invoke(entry);
+ return true;
+ }
+ catch (Exception ex) when (IsDiskError(ex))
+ {
+ Warn("Query History could not save this execution; the query itself was not affected. " + ex.Message);
+ return false;
+ }
+ });
+
+ /// Startup cleanup by age and count.
+ public Task PruneAsync() => Io.RunAsync(() =>
+ {
+ try { return Store.Prune(settings().Normalized(), clock()); }
+ catch (Exception ex) when (IsDiskError(ex)) { Warn("Query History cleanup did not complete. " + ex.Message); return 0; }
+ });
+
+ public Task ReadAsync() => Io.RunAsync(Store.ReadAll);
+ public Task DeleteAsync(IReadOnlyCollection ids) => Io.RunAsync(() => Store.Delete(ids));
+ public Task SetPinnedAsync(QueryHistoryKey key, bool pinned) => Io.RunAsync(() => Store.SetPinned(key, pinned));
+ public Task ClearAsync() => Io.RunAsync(Store.Clear);
+ public QueryHistorySettings CurrentSettings => settings().Normalized();
+
+ private void Warn(string message) { try { Warning?.Invoke(message); } catch (InvalidOperationException) { } }
+
+ internal static bool IsDiskError(Exception ex) => ex is IOException or UnauthorizedAccessException or InvalidDataException or CryptographicException or JsonException or ArgumentException or NotSupportedException;
+
+ /// True when a saved profile for this server and port has "Do not record query history" on. Host names compare without case.
+ public static bool ProfileExcludes(string profilePath, string server, int port) =>
+ File.Exists(profilePath) && new ConnectionProfileStore(profilePath).Load().Any(p => p.DisableQueryHistory && p.Port == port && string.Equals(p.Host, server, StringComparison.OrdinalIgnoreCase));
+}
diff --git a/src/PGMS.Desktop/QueryHistoryStore.cs b/src/PGMS.Desktop/QueryHistoryStore.cs
new file mode 100644
index 0000000..79d51e3
--- /dev/null
+++ b/src/PGMS.Desktop/QueryHistoryStore.cs
@@ -0,0 +1,305 @@
+using System.IO;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+
+namespace PGMS.Desktop;
+
+/// What Query History holds now. UnreadableFiles counts files that failed to decrypt or validate; they are kept on disk untouched.
+public sealed record QueryHistorySnapshot(IReadOnlyList Entries, IReadOnlySet Pins, int UnreadableFiles);
+
+/// Query History on disk (PGMS-043): executions in segment files of about 1 MiB of JSON each, compressed and encrypted with DPAPI for the current Windows user,
+/// like the recovery drafts. The entropy includes the file name, so a renamed or copied segment does not decrypt. Nothing is ever sent to a server.
+/// Writes take an exclusive lock file, so two PGMS processes of the same user do not lose each other's executions.
+public sealed class QueryHistoryStore
+{
+ public const int SegmentTargetBytes = 1024 * 1024;
+ private const int MaximumEncryptedBytes = 3 * 1024 * 1024;
+ internal const string SegmentPattern = "*.qhs", PinsFileName = "pins.qhp", LockFileName = "history.lock";
+ private static readonly byte[] Purpose = "PGMS query history 1\0"u8.ToArray();
+ private static readonly JsonSerializerOptions WriteOptions = new() { Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping };
+ private sealed record SegmentEnvelope(int Version, string Name, List Entries);
+ private sealed record PinsEnvelope(int Version, List Pins);
+ private sealed record SegmentInfo(DateTime WriteTimeUtc, long Length, int Count, DateTime OldestUtc);
+ private readonly Dictionary infos = new(StringComparer.OrdinalIgnoreCase);
+ private readonly object sync = new();
+ private DateTime lastAgePrune = DateTime.MinValue;
+
+ public QueryHistoryStore(string directory) => DirectoryPath = Path.GetFullPath(directory);
+ public string DirectoryPath { get; }
+ /// %LOCALAPPDATA%\PGMS\history, or history under PGMS_STATE_DIRECTORY; PGMS_HISTORY_DIRECTORY overrides both (tests).
+ public static string DefaultDirectory => Environment.GetEnvironmentVariable("PGMS_HISTORY_DIRECTORY") is { Length: > 0 } explicitPath ? explicitPath :
+ Path.Combine(Environment.GetEnvironmentVariable("PGMS_STATE_DIRECTORY") ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "PGMS"), "history");
+ /// How long a gate wait lasts before a write gives up; tests shorten it.
+ internal TimeSpan LockTimeout { get; set; } = TimeSpan.FromSeconds(10);
+ /// The age limit is applied at startup and then at most this often while writing.
+ internal static readonly TimeSpan AgePruneInterval = TimeSpan.FromHours(1);
+
+ /// Adds one execution and then applies the limits.
+ public void Append(QueryHistoryEntry entry, QueryHistorySettings settings, DateTime nowUtc)
+ {
+ entry.Validate();
+ lock (sync)
+ {
+ using var gate = Gate();
+ var names = Segments();
+ string? name = null; List entries = [];
+ if (names.Count > 0 && TryReadSegment(names[^1], out var last))
+ {
+ last.Add(entry);
+ byte[] probe = Serialize(names[^1], last);
+ bool fits = probe.Length <= SegmentTargetBytes || last.Count == 1;
+ CryptographicOperations.ZeroMemory(probe);
+ if (fits) { name = names[^1]; entries = last; }
+ }
+ if (name is null) { name = NewSegmentName(nowUtc, names); entries = [entry]; }
+ WriteSegment(name, entries);
+ PruneCore(settings.Normalized(), nowUtc, force: false);
+ }
+ }
+
+ /// Applies the limits now (startup); returns how many executions were removed.
+ public int Prune(QueryHistorySettings settings, DateTime nowUtc)
+ {
+ lock (sync)
+ {
+ if (!Directory.Exists(DirectoryPath)) return 0;
+ using var gate = Gate();
+ return PruneCore(settings.Normalized(), nowUtc, force: true);
+ }
+ }
+
+ public QueryHistorySnapshot ReadAll()
+ {
+ lock (sync)
+ {
+ if (!Directory.Exists(DirectoryPath)) return new([], new HashSet(), 0);
+ QueryRecoveryStore.RejectLink(DirectoryPath);
+ var entries = new List(); int unreadable = 0;
+ foreach (string name in Segments())
+ if (TryReadSegment(name, out var read)) entries.AddRange(read); else unreadable++;
+ if (!TryReadPins(out var pins)) unreadable++;
+ return new(entries, pins, unreadable);
+ }
+ }
+
+ /// Removes the given executions; returns how many were found.
+ public int Delete(IReadOnlyCollection ids)
+ {
+ lock (sync)
+ {
+ if (!Directory.Exists(DirectoryPath) || ids.Count == 0) return 0;
+ using var gate = Gate();
+ var remove = ids.ToHashSet(); int removed = 0;
+ foreach (string name in Segments())
+ {
+ if (!TryReadSegment(name, out var entries)) continue;
+ int count = entries.RemoveAll(e => remove.Contains(e.Id));
+ if (count > 0) { WriteSegment(name, entries); removed += count; }
+ }
+ return removed;
+ }
+ }
+
+ /// A pinned query is listed first and its newest execution is never removed by the limits.
+ public void SetPinned(QueryHistoryKey key, bool pinned)
+ {
+ lock (sync)
+ {
+ Directory.CreateDirectory(DirectoryPath); QueryRecoveryStore.RejectLink(DirectoryPath);
+ using var gate = Gate();
+ if (!TryReadPins(out var pins)) throw new InvalidDataException("The pinned queries file could not be read; it was kept unchanged. Clear History removes it.");
+ if (pinned ? !pins.Add(key) : !pins.Remove(key)) return;
+ WriteProtected(PinsFileName, JsonSerializer.SerializeToUtf8Bytes(new PinsEnvelope(1, pins.ToList()), WriteOptions));
+ }
+ }
+
+ /// Deletes every history file, readable or not, and the pins. Returns the number of executions that were readable.
+ public int Clear()
+ {
+ lock (sync)
+ {
+ if (!Directory.Exists(DirectoryPath)) return 0;
+ using var gate = Gate();
+ int count = 0;
+ foreach (string name in Segments()) { if (TryReadSegment(name, out var entries)) count += entries.Count; File.Delete(PathOf(name)); }
+ foreach (string partial in Directory.EnumerateFiles(DirectoryPath, "*.partial")) File.Delete(partial);
+ if (File.Exists(PathOf(PinsFileName))) File.Delete(PathOf(PinsFileName));
+ infos.Clear();
+ return count;
+ }
+ }
+
+ // ---- limits ----
+ private int PruneCore(QueryHistorySettings settings, DateTime nowUtc, bool force)
+ {
+ var names = Segments();
+ int total = 0; DateTime oldest = DateTime.MaxValue;
+ foreach (string name in names)
+ if (Info(name) is { } info) { total += info.Count; if (info.OldestUtc < oldest) oldest = info.OldestUtc; }
+ bool ageDue = force || nowUtc - lastAgePrune >= AgePruneInterval;
+ if (ageDue) lastAgePrune = nowUtc;
+ DateTime cutoff = nowUtc.AddDays(-settings.RetentionDays);
+ bool overAge = ageDue && oldest < cutoff, overCount = total > settings.MaxExecutions;
+ if (!overAge && !overCount) return 0;
+ // Rare: read everything, so the newest execution of each pinned query is known.
+ var segments = new List<(string Name, List Entries)>();
+ foreach (string name in names) if (TryReadSegment(name, out var entries)) segments.Add((name, entries));
+ var pins = TryReadPins(out var readPins) ? readPins : [];
+ var all = segments.SelectMany(s => s.Entries).ToList();
+ var kept = all.Where(e => pins.Contains(e.Key)).GroupBy(e => e.Key).Select(g => g.MaxBy(e => e.StartedUtc)!.Id).ToHashSet();
+ var remove = new HashSet();
+ if (overAge) foreach (var e in all) if (e.StartedUtc < cutoff && !kept.Contains(e.Id)) remove.Add(e.Id);
+ int remaining = all.Count - remove.Count;
+ // Past the count limit, the oldest go until 95% of it is left, so a full history is not rewritten on every execution.
+ if (remaining > settings.MaxExecutions)
+ {
+ int target = settings.MaxExecutions - Math.Max(1, settings.MaxExecutions / 20);
+ foreach (var e in all.Where(e => !remove.Contains(e.Id) && !kept.Contains(e.Id)).OrderBy(e => e.StartedUtc).ThenBy(e => e.Id))
+ {
+ if (remaining <= target) break;
+ remove.Add(e.Id); remaining--;
+ }
+ }
+ if (remove.Count == 0) return 0;
+ foreach (var (name, entries) in segments)
+ if (entries.RemoveAll(e => remove.Contains(e.Id)) > 0) WriteSegment(name, entries);
+ return remove.Count;
+ }
+
+ // ---- files ----
+ private string PathOf(string name) => Path.Combine(DirectoryPath, name);
+
+ private List Segments()
+ {
+ if (!Directory.Exists(DirectoryPath)) return [];
+ return Directory.EnumerateFiles(DirectoryPath, SegmentPattern).Select(Path.GetFileName).Where(n => n is not null && n.EndsWith(".qhs", StringComparison.OrdinalIgnoreCase))
+ .Select(n => n!).OrderBy(n => n, StringComparer.Ordinal).ToList();
+ }
+
+ private static string NewSegmentName(DateTime nowUtc, List existing)
+ {
+ // Names sort in time order; a clock that went back still sorts after the last segment.
+ string stamp = nowUtc.ToString("yyyyMMdd'T'HHmmssfff", System.Globalization.CultureInfo.InvariantCulture);
+ if (existing.Count > 0 && existing[^1].Length >= stamp.Length && string.CompareOrdinal(stamp, existing[^1][..stamp.Length]) < 0) stamp = existing[^1][..stamp.Length];
+ return stamp + "-" + Guid.NewGuid().ToString("N") + ".qhs";
+ }
+
+ private FileStream Gate()
+ {
+ Directory.CreateDirectory(DirectoryPath); QueryRecoveryStore.RejectLink(DirectoryPath);
+ string path = PathOf(LockFileName);
+ var clock = System.Diagnostics.Stopwatch.StartNew();
+ while (true)
+ {
+ try
+ {
+ if (File.Exists(path)) QueryRecoveryStore.RejectLink(path);
+ return new FileStream(path, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None);
+ }
+ catch (IOException) when (clock.Elapsed < LockTimeout) { Thread.Sleep(25); }
+ }
+ }
+
+ private SegmentInfo? Info(string name)
+ {
+ var file = new FileInfo(PathOf(name));
+ if (!file.Exists) return null;
+ if (infos.TryGetValue(name, out var cached) && cached.WriteTimeUtc == file.LastWriteTimeUtc && cached.Length == file.Length) return cached;
+ return TryReadSegment(name, out _) && infos.TryGetValue(name, out var read) ? read : null;
+ }
+
+ private bool TryReadSegment(string name, out List entries)
+ {
+ try { entries = ReadSegment(name); return true; }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException or CryptographicException or JsonException or NotSupportedException)
+ { entries = []; infos.Remove(name); return false; }
+ }
+
+ internal List ReadSegment(string name)
+ {
+ byte[] clear = ReadProtected(name);
+ try
+ {
+ var envelope = JsonSerializer.Deserialize(clear);
+ if (envelope is null || envelope.Version != 1 || envelope.Entries is null || !string.Equals(envelope.Name, name, StringComparison.OrdinalIgnoreCase) || envelope.Entries.Any(e => e is null))
+ throw new InvalidDataException("Query History file has an unsupported format or identity.");
+ foreach (var entry in envelope.Entries) entry.Validate();
+ var file = new FileInfo(PathOf(name));
+ infos[name] = new(file.LastWriteTimeUtc, file.Length, envelope.Entries.Count, envelope.Entries.Count == 0 ? DateTime.MaxValue : envelope.Entries.Min(e => e.StartedUtc));
+ return envelope.Entries;
+ }
+ finally { CryptographicOperations.ZeroMemory(clear); }
+ }
+
+ private static byte[] Serialize(string name, List entries) => JsonSerializer.SerializeToUtf8Bytes(new SegmentEnvelope(1, name, entries), WriteOptions);
+
+ private void WriteSegment(string name, List entries)
+ {
+ if (entries.Count == 0) { File.Delete(PathOf(name)); infos.Remove(name); return; }
+ byte[] clear = Serialize(name, entries);
+ WriteProtected(name, clear);
+ var file = new FileInfo(PathOf(name));
+ infos[name] = new(file.LastWriteTimeUtc, file.Length, entries.Count, entries.Min(e => e.StartedUtc));
+ }
+
+ private bool TryReadPins(out HashSet pins)
+ {
+ pins = [];
+ if (!File.Exists(PathOf(PinsFileName))) return true;
+ try
+ {
+ byte[] clear = ReadProtected(PinsFileName);
+ try
+ {
+ var envelope = JsonSerializer.Deserialize(clear);
+ if (envelope is null || envelope.Version != 1 || envelope.Pins is null || envelope.Pins.Any(p => p is null || p.Server is null || p.Database is null || p.Hash is not { Length: 64 }))
+ throw new InvalidDataException("Unsupported pinned queries file.");
+ pins = envelope.Pins.ToHashSet();
+ return true;
+ }
+ finally { CryptographicOperations.ZeroMemory(clear); }
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException or CryptographicException or JsonException or NotSupportedException) { return false; }
+ }
+
+ private static byte[] Entropy(string name) => [.. Purpose, .. Encoding.UTF8.GetBytes(name.ToLowerInvariant())];
+
+ private byte[] ReadProtected(string name)
+ {
+ string path = PathOf(name);
+ QueryRecoveryStore.RejectLink(DirectoryPath); QueryRecoveryStore.RejectLink(path);
+ byte[] encrypted;
+ using (var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read))
+ {
+ if (stream.Length > MaximumEncryptedBytes) throw new InvalidDataException("Query History file exceeds its size limit.");
+ encrypted = new byte[checked((int)stream.Length)]; stream.ReadExactly(encrypted);
+ }
+ byte[] payload = ProtectedData.Unprotect(encrypted, Entropy(name), DataProtectionScope.CurrentUser);
+ try
+ {
+ // Uncompressed payloads come back as the same array, which is zeroed below.
+ byte[] clear = RecoveryPayload.Decode(payload);
+ return ReferenceEquals(clear, payload) ? (byte[])payload.Clone() : clear;
+ }
+ finally { CryptographicOperations.ZeroMemory(payload); }
+ }
+
+ private void WriteProtected(string name, byte[] clear)
+ {
+ byte[] payload;
+ try { payload = RecoveryPayload.Encode(clear); }
+ finally { CryptographicOperations.ZeroMemory(clear); }
+ byte[] encrypted;
+ try { encrypted = ProtectedData.Protect(payload, Entropy(name), DataProtectionScope.CurrentUser); }
+ finally { CryptographicOperations.ZeroMemory(payload); }
+ string path = PathOf(name), temporary = path + "." + Guid.NewGuid().ToString("N") + ".partial";
+ if (File.Exists(path)) QueryRecoveryStore.RejectLink(path);
+ try
+ {
+ using (var stream = new FileStream(temporary, FileMode.CreateNew, FileAccess.Write, FileShare.None)) { stream.Write(encrypted); stream.Flush(true); }
+ File.Move(temporary, path, true);
+ }
+ finally { if (File.Exists(temporary)) File.Delete(temporary); }
+ }
+}
diff --git a/src/PGMS.Desktop/QueryHistoryWindow.xaml b/src/PGMS.Desktop/QueryHistoryWindow.xaml
new file mode 100644
index 0000000..78da99c
--- /dev/null
+++ b/src/PGMS.Desktop/QueryHistoryWindow.xaml
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/PGMS.Desktop/QueryHistoryWindow.xaml.cs b/src/PGMS.Desktop/QueryHistoryWindow.xaml.cs
new file mode 100644
index 0000000..c524c69
--- /dev/null
+++ b/src/PGMS.Desktop/QueryHistoryWindow.xaml.cs
@@ -0,0 +1,248 @@
+using System.Globalization;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Input;
+
+namespace PGMS.Desktop;
+
+/// A row of the Query History grid: a distinct query (newest execution) or, under an expanded query, one of its executions.
+public sealed class QueryHistoryRow(QueryHistoryGroup group, QueryHistoryEntry entry, bool isGroup, bool expanded)
+{
+ public QueryHistoryGroup Group { get; } = group;
+ public QueryHistoryEntry Entry { get; } = entry;
+ public bool IsGroup { get; } = isGroup;
+ public bool Expanded { get; } = expanded;
+ public bool CanExpand => IsGroup && Group.Executions.Count > 1;
+ public string Toggle => CanExpand ? (Expanded ? "−" : "+") : "";
+ public Visibility ToggleVisibility => CanExpand ? Visibility.Visible : Visibility.Collapsed;
+ public string ToggleName => Expanded ? "Hide executions" : "Show executions";
+ public string PinMark => IsGroup && Group.Pinned ? "★" : "";
+ public string When => Entry.StartedUtc.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.CurrentCulture);
+ public string Server => $"{Entry.Server}:{Entry.Port}";
+ public string Database => Entry.Database;
+ public string User => Entry.User;
+ public string Executions => IsGroup ? Group.Executions.Count.ToString("N0", CultureInfo.CurrentCulture) : "";
+ public string Duration => QueryHistoryWindow.FormatDuration(Entry.DurationMilliseconds);
+ public string Rows => Entry.RowsAffected is { } affected ? $"{affected:N0} affected" : Entry.RowsReturned is { } returned ? returned.ToString("N0", CultureInfo.CurrentCulture) : "";
+ public string Result => Entry.Outcome switch { QueryHistoryOutcome.Succeeded => "Succeeded", QueryHistoryOutcome.Canceled => "Canceled", _ => Entry.SqlState is { } state ? "Error " + state : "Error" };
+ public string Origin => QueryHistoryWindow.OriginLabel(Entry.Origin);
+ public string Sql => (IsGroup ? "" : " ") + QueryHistoryText.Preview(Entry.Sql);
+ public string AccessibleName => (IsGroup ? (Group.Pinned ? "Pinned query, " : "Query, ") + $"{Executions} run(s), last " : "Execution ") + $"{When}, {Server}/{Database}, {Result}: {QueryHistoryText.Preview(Entry.Sql, 80)}";
+}
+
+/// View ▸ Query History… (PGMS-043). Lists the executions recorded on this computer, one row per distinct query, and reopens them in a query tab.
+/// Everything is read from and written to the local encrypted history through the recorder's queue; nothing here talks to a server.
+public partial class QueryHistoryWindow : Window
+{
+ internal const string ClearQuestion = "Delete every execution in Query History, including pinned queries? This cannot be undone.";
+ private readonly QueryHistoryRecorder recorder;
+ private readonly Func openInTab;
+ private IReadOnlyList groups = [];
+ private readonly HashSet expanded = [];
+ private bool busy;
+ /// Writes the clipboard; tests replace it.
+ internal Action WriteClipboard { get; set; } = text => Clipboard.SetText(text, TextDataFormat.UnicodeText);
+ /// Asks a yes/no question; tests replace it.
+ internal Func Confirm { get; set; }
+ /// Rows shown now, in order.
+ internal IReadOnlyList Rows => (IReadOnlyList?)Entries.ItemsSource ?? [];
+ internal Task Loading { get; private set; } = Task.CompletedTask;
+
+ public QueryHistoryWindow(QueryHistoryRecorder recorder, Func openInTab)
+ {
+ this.recorder = recorder; this.openInTab = openInTab;
+ Confirm = question => MessageBox.Show(this, question, "Query History", MessageBoxButton.YesNo, MessageBoxImage.Warning, MessageBoxResult.No) == MessageBoxResult.Yes;
+ InitializeComponent(); WindowWorkArea.Attach(this);
+ Target.Text = $"Query History — executions recorded on this computer for the Windows user {Environment.UserName}.\nStored encrypted for this user (DPAPI) in {recorder.Store.DirectoryPath}; never sent to a server.";
+ ConnectionFilter.Items.Add(new ComboBoxItem { Content = "All connections", Tag = null }); ConnectionFilter.SelectedIndex = 0;
+ recorder.Recorded += OnRecorded;
+ Closed += (_, _) => recorder.Recorded -= OnRecorded;
+ }
+
+ private void OnRecorded(QueryHistoryEntry _) => Dispatcher.BeginInvoke(() => { if (IsLoaded && !busy) Loading = RefreshAsync(); });
+ private void OnLoaded(object sender, RoutedEventArgs e) => Loading = RefreshAsync();
+ private void Refresh(object sender, RoutedEventArgs e) => Loading = RefreshAsync();
+
+ public async Task RefreshAsync()
+ {
+ busy = true; RefreshButton.IsEnabled = false; Status.Text = "Reading Query History…";
+ try
+ {
+ DisabledBanner.Visibility = recorder.CurrentSettings.Enabled ? Visibility.Collapsed : Visibility.Visible;
+ var snapshot = await recorder.ReadAsync();
+ groups = QueryHistoryGroup.Build(snapshot);
+ FillConnections();
+ Apply();
+ int executions = groups.Sum(g => g.Executions.Count);
+ Status.Text = $"{groups.Count:N0} distinct quer{(groups.Count == 1 ? "y" : "ies")}, {executions:N0} execution(s)." +
+ (snapshot.UnreadableFiles > 0 ? $" {snapshot.UnreadableFiles} history file(s) could not be read (damaged, from another Windows user or renamed) and were left untouched; Clear History removes them." : "");
+ }
+ catch (Exception ex) when (QueryHistoryRecorder.IsDiskError(ex)) { Status.Text = "Query History could not be read. " + ex.Message; }
+ finally { busy = false; RefreshButton.IsEnabled = true; UpdateButtons(); }
+ }
+
+ private static string ConnectionLabel(QueryHistoryKey key) => $"{key.Server}:{key.Port} / {key.Database}";
+ private void FillConnections()
+ {
+ string? selected = (ConnectionFilter.SelectedItem as ComboBoxItem)?.Tag as string;
+ while (ConnectionFilter.Items.Count > 1) ConnectionFilter.Items.RemoveAt(1);
+ foreach (string label in groups.Select(g => ConnectionLabel(g.Key)).Distinct().OrderBy(l => l, StringComparer.OrdinalIgnoreCase))
+ ConnectionFilter.Items.Add(new ComboBoxItem { Content = label, Tag = label });
+ ConnectionFilter.SelectedItem = ConnectionFilter.Items.OfType().FirstOrDefault(i => (string?)i.Tag == selected) ?? ConnectionFilter.Items[0];
+ }
+
+ private void FilterChanged(object sender, RoutedEventArgs e) { if (IsLoaded) Apply(); }
+
+ /// Executions of the group that pass the period filter.
+ private IReadOnlyList Visible(QueryHistoryGroup group)
+ {
+ int days = PeriodFilter.SelectedItem is ComboBoxItem { Tag: string tag } ? int.Parse(tag, CultureInfo.InvariantCulture) : 0;
+ if (days == 0) return group.Executions;
+ DateTime since = days == 1 ? DateTime.Today.ToUniversalTime() : DateTime.UtcNow.AddDays(-days);
+ return group.Executions.Where(e => e.StartedUtc >= since).ToArray();
+ }
+
+ internal void Apply()
+ {
+ var keep = (Entries.SelectedItem as QueryHistoryRow) is { } current ? (current.Group.Key, current.Entry.Id) : default((QueryHistoryKey, Guid)?);
+ string text = QueryHistoryText.Normalize(TextFilter.Text);
+ string? connection = (ConnectionFilter.SelectedItem as ComboBoxItem)?.Tag as string;
+ var rows = new List();
+ foreach (var group in groups)
+ {
+ if (PinnedOnly.IsChecked == true && !group.Pinned) continue;
+ if (connection is not null && ConnectionLabel(group.Key) != connection) continue;
+ if (text.Length > 0 && !QueryHistoryText.Normalize(group.Latest.Sql).Contains(text, StringComparison.OrdinalIgnoreCase) &&
+ !group.Key.Database.Contains(text, StringComparison.OrdinalIgnoreCase) && !group.Latest.User.Contains(text, StringComparison.OrdinalIgnoreCase)) continue;
+ var visible = Visible(group);
+ if (visible.Count == 0) continue;
+ bool open = expanded.Contains(group.Key) && group.Executions.Count > 1;
+ rows.Add(new(group, visible[0], true, open));
+ if (open) rows.AddRange(visible.Select(e => new QueryHistoryRow(group, e, false, false)));
+ }
+ Entries.ItemsSource = rows;
+ if (keep is { } k) Entries.SelectedItem = rows.FirstOrDefault(r => r.Group.Key == k.Item1 && r.Entry.Id == k.Item2) ?? rows.FirstOrDefault(r => r.IsGroup && r.Group.Key == k.Item1);
+ if (Entries.SelectedItem is null && rows.Count > 0) Entries.SelectedIndex = 0;
+ ShowSelection();
+ }
+
+ internal QueryHistoryRow? Selected => Entries.SelectedItem as QueryHistoryRow;
+
+ private void SelectionChanged(object sender, SelectionChangedEventArgs e) => ShowSelection();
+ private void ShowSelection()
+ {
+ UpdateButtons();
+ if (Selected is not { } row) { SqlText.Text = ""; Details.Text = ""; return; }
+ var entry = row.Entry;
+ SqlText.Text = entry.Sql;
+ string result = entry.Outcome switch
+ {
+ QueryHistoryOutcome.Succeeded => "Succeeded",
+ QueryHistoryOutcome.Canceled => "Canceled",
+ _ => "Error" + (entry.SqlState is { } state ? " " + state : "") + (entry.Error is { } message ? ": " + message : "")
+ };
+ Details.Text = $"{(row.IsGroup ? $"Last of {row.Group.Executions.Count:N0} run(s)" : "Execution")}: started {row.When}, {row.Duration}, {result}. " +
+ $"{row.Server}/{entry.Database} as {entry.User}. Origin: {row.Origin}." + (row.Rows.Length > 0 ? $" Rows: {row.Rows}." : "") +
+ (entry.Truncated ? $" The SQL was cut at {entry.Sql.Length:N0} of {entry.OriginalLength:N0} characters when it was recorded." : "");
+ }
+
+ private void UpdateButtons()
+ {
+ bool any = Selected is not null && !busy;
+ OpenButton.IsEnabled = CopyButton.IsEnabled = DeleteButton.IsEnabled = PinButton.IsEnabled = any;
+ PinButton.Content = Selected?.Group.Pinned == true ? "Un_pin" : "_Pin";
+ ClearButton.IsEnabled = !busy;
+ }
+
+ private void ToggleRow(object sender, RoutedEventArgs e)
+ {
+ if ((sender as FrameworkElement)?.DataContext is QueryHistoryRow { CanExpand: true } row) { Entries.SelectedItem = row; SetExpanded(row, !row.Expanded); }
+ }
+ internal void SetExpanded(QueryHistoryRow row, bool open)
+ {
+ if (!row.CanExpand && open) return;
+ if (open) expanded.Add(row.Group.Key); else expanded.Remove(row.Group.Key);
+ Entries.SelectedItem = row;
+ var key = row.Group.Key;
+ Apply();
+ Entries.SelectedItem = Rows.FirstOrDefault(r => r.IsGroup && r.Group.Key == key);
+ if (Entries.SelectedItem is { } item) Entries.ScrollIntoView(item);
+ }
+
+ private void EntriesKeyDown(object sender, KeyEventArgs e)
+ {
+ if (Selected is not { } row) return;
+ switch (e.Key)
+ {
+ case Key.Right when row.IsGroup && row.CanExpand && !row.Expanded: SetExpanded(row, true); e.Handled = true; break;
+ case Key.Left when row.IsGroup && row.Expanded: SetExpanded(row, false); e.Handled = true; break;
+ case Key.Left when !row.IsGroup: SetExpanded(Rows.First(r => r.IsGroup && r.Group.Key == row.Group.Key), false); e.Handled = true; break;
+ case Key.Enter: _ = OpenAsync(); e.Handled = true; break;
+ case Key.Delete: _ = DeleteAsync(); e.Handled = true; break;
+ case Key.C when Keyboard.Modifiers == ModifierKeys.Control: CopySql(); e.Handled = true; break;
+ }
+ }
+ private void EntriesDoubleClick(object sender, MouseButtonEventArgs e) { if (e.OriginalSource is DependencyObject source && FindRow(source) is not null) _ = OpenAsync(); }
+ private static DataGridRow? FindRow(DependencyObject? element)
+ {
+ while (element is not null and not DataGridRow) element = System.Windows.Media.VisualTreeHelper.GetParent(element);
+ return element as DataGridRow;
+ }
+
+ private void Open(object sender, RoutedEventArgs e) => _ = OpenAsync();
+ internal async Task OpenAsync()
+ {
+ if (Selected is not { } row) return;
+ try { await openInTab(row.Entry); Status.Text = "Opened in a new query tab. Nothing was executed."; }
+ catch (Exception ex) when (ex is InvalidOperationException or Npgsql.NpgsqlException or System.IO.IOException) { Status.Text = "The query tab could not be opened. " + ex.Message; }
+ }
+
+ private void Copy(object sender, RoutedEventArgs e) => CopySql();
+ internal void CopySql()
+ {
+ if (Selected is not { } row) return;
+ try { WriteClipboard(row.Entry.ReopenText); Status.Text = $"Copied {row.Entry.Sql.Length:N0} character(s) of SQL" + (row.Entry.Truncated ? " (cut when recorded; the copy says so in a last comment line)." : "."); }
+ catch (System.Runtime.InteropServices.ExternalException) { Status.Text = "Copy failed: Windows could not update the clipboard. Try again."; }
+ }
+
+ private void TogglePin(object sender, RoutedEventArgs e) => _ = TogglePinAsync();
+ internal async Task TogglePinAsync()
+ {
+ if (Selected is not { } row || busy) return;
+ bool pin = !row.Group.Pinned;
+ try { await recorder.SetPinnedAsync(row.Group.Key, pin); await RefreshAsync(); Status.Text = pin ? "Pinned: listed first, and its newest execution is always kept." : "Unpinned."; }
+ catch (Exception ex) when (QueryHistoryRecorder.IsDiskError(ex)) { Status.Text = "The pin was not saved. " + ex.Message; }
+ }
+
+ private void Delete(object sender, RoutedEventArgs e) => _ = DeleteAsync();
+ internal async Task DeleteAsync()
+ {
+ if (Selected is not { } row || busy) return;
+ var ids = row.IsGroup ? row.Group.Executions.Select(x => x.Id).ToArray() : [row.Entry.Id];
+ string question = row.IsGroup ? $"Delete this query and its {ids.Length:N0} execution(s) from Query History?" : "Delete this execution from Query History?";
+ if (!Confirm(question)) return;
+ try
+ {
+ int removed = await recorder.DeleteAsync(ids);
+ if (row.IsGroup && row.Group.Pinned) await recorder.SetPinnedAsync(row.Group.Key, false);
+ await RefreshAsync();
+ Status.Text = $"Deleted {removed:N0} execution(s).";
+ }
+ catch (Exception ex) when (QueryHistoryRecorder.IsDiskError(ex)) { Status.Text = "Nothing was deleted. " + ex.Message; }
+ }
+
+ private void Clear(object sender, RoutedEventArgs e) => _ = ClearAsync();
+ internal async Task ClearAsync()
+ {
+ if (busy || !Confirm(ClearQuestion)) return;
+ try { int removed = await recorder.ClearAsync(); expanded.Clear(); await RefreshAsync(); Status.Text = $"Query History cleared: {removed:N0} execution(s) removed."; }
+ catch (Exception ex) when (QueryHistoryRecorder.IsDiskError(ex)) { Status.Text = "Query History was not cleared. " + ex.Message; }
+ }
+
+ internal static string FormatDuration(long milliseconds) => milliseconds < 1000 ? $"{milliseconds:N0} ms" :
+ milliseconds < 60000 ? (milliseconds / 1000.0).ToString("0.000", CultureInfo.CurrentCulture) + " s" : TimeSpan.FromMilliseconds(milliseconds).ToString(@"h\:mm\:ss", CultureInfo.InvariantCulture);
+ internal static string OriginLabel(QueryHistoryOrigin origin) => origin switch
+ {
+ QueryHistoryOrigin.QueryDesigner => "Query Designer", QueryHistoryOrigin.Ai => "AI", QueryHistoryOrigin.Other => "Other", _ => "Typed"
+ };
+}
diff --git a/src/PGMS.Desktop/QueryView.Results.cs b/src/PGMS.Desktop/QueryView.Results.cs
index 2d653b0..4919c1a 100644
--- a/src/PGMS.Desktop/QueryView.Results.cs
+++ b/src/PGMS.Desktop/QueryView.Results.cs
@@ -70,6 +70,8 @@ private async Task ExecuteAsTextAsync(string sql, int submittedStart, long submi
displayLimited = false; resultsIncomplete = true;
UpdateExport();
int displayedRows = 0, noticeIndex = 0, shown = 0;
+ DateTime startedUtc = DateTime.UtcNow;
+ Exception? failure = null; bool canceled = false; long? affectedRows = null;
var timer = Stopwatch.StartNew();
var screen = new StringWriter();
StringWriter? messagesOut = toFile ? new StringWriter() : null;
@@ -106,7 +108,7 @@ void Show()
var lastStatus = Stopwatch.StartNew();
int nextFlush = 100;
var meter = StartClientTrial(sql);
- await session!.StreamAsync(sql, Measured(meter, async (batch, token) =>
+ var completion = await session!.StreamAsync(sql, Measured(meter, async (batch, token) =>
{
if (toFile)
{
@@ -132,6 +134,7 @@ await Dispatcher.InvokeAsync(() =>
}, System.Windows.Threading.DispatcherPriority.Background, token);
}), options, cancellation.Token);
RecordClientTrial(meter);
+ affectedRows = completion.RecordsAffected >= 0 ? completion.RecordsAffected : null;
succeeded = true;
resultsIncomplete = false;
DrainNotices();
@@ -142,6 +145,7 @@ await Dispatcher.InvokeAsync(() =>
}
catch (Exception ex) when (ex is NpgsqlException or OperationCanceledException or InvalidOperationException or IOException or UnauthorizedAccessException)
{
+ failure = ex; canceled = ex is OperationCanceledException || cancellation?.IsCancellationRequested == true;
if (ex is PostgresException positioned) SetErrorPosition(sql, positioned.Position, submittedStart, submittedRevision);
string error = ex is PostgresException pg ? QueryErrorFormatter.Format(pg) :
ex is OperationCanceledException ? "Query canceled. An explicit transaction may require ROLLBACK before further commands." : ex.Message;
@@ -168,6 +172,7 @@ await Dispatcher.InvokeAsync(() =>
cancellation = null;
UpdateExport();
StateChanged?.Invoke(this, EventArgs.Empty);
+ RecordHistory(sql, startedUtc, timer.Elapsed, failure, canceled, displayedRows, affectedRows);
}
}
}
diff --git a/src/PGMS.Desktop/QueryView.Session.cs b/src/PGMS.Desktop/QueryView.Session.cs
index 37af5ea..993ecdb 100644
--- a/src/PGMS.Desktop/QueryView.Session.cs
+++ b/src/PGMS.Desktop/QueryView.Session.cs
@@ -29,6 +29,40 @@ internal async Task ChangeDatabaseAsync(string connectionString)
finally{IsBusy=false;cancellation.Dispose();cancellation=null;UpdateExport();StateChanged?.Invoke(this,EventArgs.Empty);}
await previous.DisposeAsync();
}
+ // ---- Query History (PGMS-043) ----
+ /// Receives every execution of this tab after it ends; MainWindow points it at Query History. Null records nothing.
+ internal Action? HistorySink { get; set; }
+ private readonly List<(string Normalized, QueryHistoryOrigin Origin)> originMarks = [];
+ /// Text put in the editor by a tool (Query Designer, AI, Script as and similar). It keeps that origin while it runs unchanged.
+ internal void MarkOrigin(string sql, QueryHistoryOrigin origin)
+ {
+ if (origin == QueryHistoryOrigin.Typed || QueryHistoryText.Normalize(sql) is not { Length: > 0 } normalized) return;
+ originMarks.RemoveAll(m => m.Normalized == normalized);
+ originMarks.Add((normalized, origin));
+ if (originMarks.Count > 8) originMarks.RemoveAt(0);
+ }
+ /// Origin of the SQL that runs: a marked text that it equals or contains, the latest first; otherwise typed.
+ internal QueryHistoryOrigin OriginOf(string sql)
+ {
+ string normalized = QueryHistoryText.Normalize(sql);
+ for (int i = originMarks.Count - 1; i >= 0; i--)
+ if (normalized.Contains(originMarks[i].Normalized, StringComparison.Ordinal)) return originMarks[i].Origin;
+ return QueryHistoryOrigin.Typed;
+ }
+ /// The one place an execution is handed to Query History, after it ended. Never throws and never waits for the disk.
+ private void RecordHistory(string sql, DateTime startedUtc, TimeSpan elapsed, Exception? error, bool canceled, long? rowsReturned, long? rowsAffected, QueryHistoryOrigin? origin = null)
+ {
+ if (HistorySink is not { } sink || ConnectionString is not { } connection) return;
+ var outcome = canceled ? QueryHistoryOutcome.Canceled : error is null ? QueryHistoryOutcome.Succeeded : QueryHistoryOutcome.Failed;
+ try { sink(QueryHistoryCapture.FromConnection(connection, session?.Database, sql, startedUtc, elapsed, origin ?? OriginOf(sql), outcome, rowsReturned, rowsAffected, error)); }
+ catch (Exception ex) when (ex is ArgumentException or FormatException or InvalidOperationException or IOException or InvalidDataException)
+ { Messages.AppendText("\nQuery History did not record this execution: " + ex.Message); }
+ }
+
+ /// Execute SQL inside Design Query in Editor runs on the designer's own connection to this tab's server and database.
+ internal void RecordDesignerExecution(string sql, DateTime startedUtc, TimeSpan elapsed, Exception? error, bool canceled, long? rows) =>
+ RecordHistory(sql, startedUtc, elapsed, error, canceled, rows, null, QueryHistoryOrigin.QueryDesigner);
+
private async void InspectSession(object sender,RoutedEventArgs e)=>await InspectSessionAsync();
private void ShowSessionSnapshot(QuerySessionSnapshot snapshot)
{
diff --git a/src/PGMS.Desktop/QueryView.xaml.cs b/src/PGMS.Desktop/QueryView.xaml.cs
index ff2e69c..85e419a 100644
--- a/src/PGMS.Desktop/QueryView.xaml.cs
+++ b/src/PGMS.Desktop/QueryView.xaml.cs
@@ -40,6 +40,7 @@ public partial class QueryView : UserControl, IAsyncDisposable
internal void InsertDesignedSql(string sql, (int Start, int Length)? range = null)
{
sql = sql.Replace("\r\n", "\n", StringComparison.Ordinal).Replace("\n", Environment.NewLine, StringComparison.Ordinal);
+ MarkOrigin(sql, QueryHistoryOrigin.QueryDesigner);
int start = range?.Start ?? (Editor.SelectionLength > 0 ? Editor.SelectionStart : Editor.CaretOffset);
Editor.Document.Replace(start, range?.Length ?? Editor.SelectionLength, sql);
Editor.Select(start + sql.Length, 0);
@@ -50,6 +51,7 @@ internal void InsertDesignedSql(string sql, (int Start, int Length)? range = nul
internal void InsertGeneratedSql(string sql)
{
sql = sql.Replace("\r\n", "\n", StringComparison.Ordinal).Replace("\n", Environment.NewLine, StringComparison.Ordinal);
+ MarkOrigin(sql, QueryHistoryOrigin.Ai);
if (Editor.SelectionLength > 0) { int start = Editor.SelectionStart; Editor.SelectedText = sql; Editor.Select(start, sql.Length); }
else
{
@@ -187,6 +189,8 @@ public async Task ExecuteAsync()
resultsIncomplete = true;
UpdateExport();
long displayedRows = 0;
+ DateTime startedUtc = DateTime.UtcNow;
+ Exception? failure = null; bool canceled = false; long? affectedRows = null;
var timer = Stopwatch.StartNew();
var loader = new GridResultLoader(this, QueryOptions.GridPageRows);
try
@@ -195,7 +199,8 @@ public async Task ExecuteAsync()
var meter = StartClientTrial(sql);
var result = await session.StreamAsync(sql, Measured(meter, (batch, _) => { loader.Accept(batch); return ValueTask.CompletedTask; }), options, cancellation.Token);
loader.Drain(everything: true);
- displayedRows = loader.Received;
+ displayedRows = result.DisplayedRows;
+ affectedRows = result.RecordsAffected >= 0 ? result.RecordsAffected : null;
RecordClientTrial(meter);
resultsIncomplete = false;
displayLimited = result.Truncated;
@@ -212,6 +217,7 @@ public async Task ExecuteAsync()
{
loader.Drain(everything: true);
displayedRows = loader.Received;
+ failure = ex; canceled = ex is OperationCanceledException || cancellation?.IsCancellationRequested == true;
if(ex is PostgresException positioned)SetErrorPosition(sql,positioned.Position,submittedStart,submittedRevision);
Messages.Text = ex is PostgresException pg ? QueryErrorFormatter.Format(pg) :
ex is OperationCanceledException ? "Query canceled. An explicit transaction may require ROLLBACK before further commands." : ex.Message;
@@ -235,6 +241,7 @@ public async Task ExecuteAsync()
cancellation = null;
UpdateExport();
StateChanged?.Invoke(this, EventArgs.Empty);
+ RecordHistory(sql, startedUtc, timer.Elapsed, failure, canceled, displayedRows, affectedRows);
}
}
diff --git a/tests/PGMS.DesktopTests/Program.cs b/tests/PGMS.DesktopTests/Program.cs
index 0512d83..a031baf 100644
--- a/tests/PGMS.DesktopTests/Program.cs
+++ b/tests/PGMS.DesktopTests/Program.cs
@@ -18,6 +18,9 @@ internal static class Program
[STAThread]
private static void Main(string[] args)
{
+ // Query History holds SQL: tests always record into a throwaway folder, never the user's profile (child processes inherit it).
+ if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("PGMS_HISTORY_DIRECTORY")))
+ Environment.SetEnvironmentVariable("PGMS_HISTORY_DIRECTORY", Path.GetFullPath(Path.Combine("artifacts", "history-tests", "process-" + Guid.NewGuid().ToString("N"))));
if (args.Length == 2 && args[0] == "--query-close-checks") { Environment.ExitCode = QueryCloseCommandChecks.RunIsolated(args[1]); return; }
if (args.Length == 4 && args[0] == "--query-close-child") { Environment.ExitCode = QueryCloseCommandChecks.RunChild(args); return; }
if (args.Length == 2 && args[0] == "--uia-isolated") { Environment.ExitCode = IsolatedTextAutomationChecks.Run(args[1]); return; }
@@ -81,7 +84,7 @@ private static void Main(string[] args)
};
app.Run(preview); return;
}
- if (args.Contains("--representative") || args.Contains("--sessions") || args.Contains("--plans") || args.Contains("--export-roundtrip") || args.Contains("--export") || args.Contains("--query-statistics") || args.Contains("--activity") || args.Contains("--highlighting") || args.Contains("--global-backup") || args.Contains("--column-types") || args.Contains("--index-maintenance") || args.Contains("--index-expressions") || args.Contains("--partition-tables") || args.Contains("--cascade-journal") || args.Contains("--default-process") || args.Contains("--default-cancel") || args.Contains("--default-ui") || args.Contains("--default-journal") || args.Contains("--routine-boundaries") || args.Contains("--routine-process") || args.Contains("--routine-privileges") || args.Contains("--backup-formats") || args.Contains("--identity-columns") || args.Contains("--restore-ownership") || args.Contains("--privilege-journal") || args.Contains("--maintain-privilege") || args.Contains("--privilege-commit") || args.Contains("--unicode-password") || args.Contains("--server-tls") || args.Contains("--ai-settings") || args.Contains("--schema-context") || args.Contains("--sql-links") || args.Contains("--editor-factors") || args.Contains("--snapshots") || args.Contains("--row-names") || args.Contains("--membership-options") || args.Contains("--column-privileges") || args.Contains("--privilege-cancellation") || args.Contains("--sequence-privileges") || args.Contains("--privilege-commands") || args.Contains("--effective-privileges") || args.Contains("--copy") || args.Contains("--sequence-owner") || args.Contains("--sequence-state") || args.Contains("--open-long-line") || args.Contains("--open-large") || args.Contains("--open-load") || args.Contains("--recovery-save") || args.Contains("--recovery-load") || args.Contains("--recovery") || args.Contains("--files") || args.Contains("--properties") || args.Contains("--explorer") || args.Contains("--reports") || args.Contains("--table-design") || args.Contains("--diagrams") || args.Contains("--advanced-index") || args.Contains("--sequences") || args.Contains("--accessibility") || args.Contains("--results-text") || args.Contains("--connection") || args.Contains("--streaming") || args.Contains("--replace"))
+ if (args.Contains("--representative") || args.Contains("--sessions") || args.Contains("--plans") || args.Contains("--export-roundtrip") || args.Contains("--export") || args.Contains("--query-statistics") || args.Contains("--activity") || args.Contains("--highlighting") || args.Contains("--global-backup") || args.Contains("--column-types") || args.Contains("--index-maintenance") || args.Contains("--index-expressions") || args.Contains("--partition-tables") || args.Contains("--cascade-journal") || args.Contains("--default-process") || args.Contains("--default-cancel") || args.Contains("--default-ui") || args.Contains("--default-journal") || args.Contains("--routine-boundaries") || args.Contains("--routine-process") || args.Contains("--routine-privileges") || args.Contains("--backup-formats") || args.Contains("--identity-columns") || args.Contains("--restore-ownership") || args.Contains("--privilege-journal") || args.Contains("--maintain-privilege") || args.Contains("--privilege-commit") || args.Contains("--unicode-password") || args.Contains("--server-tls") || args.Contains("--ai-settings") || args.Contains("--schema-context") || args.Contains("--sql-links") || args.Contains("--editor-factors") || args.Contains("--snapshots") || args.Contains("--row-names") || args.Contains("--membership-options") || args.Contains("--column-privileges") || args.Contains("--privilege-cancellation") || args.Contains("--sequence-privileges") || args.Contains("--privilege-commands") || args.Contains("--effective-privileges") || args.Contains("--copy") || args.Contains("--sequence-owner") || args.Contains("--sequence-state") || args.Contains("--open-long-line") || args.Contains("--open-large") || args.Contains("--open-load") || args.Contains("--recovery-save") || args.Contains("--recovery-load") || args.Contains("--recovery") || args.Contains("--files") || args.Contains("--properties") || args.Contains("--explorer") || args.Contains("--reports") || args.Contains("--table-design") || args.Contains("--diagrams") || args.Contains("--advanced-index") || args.Contains("--sequences") || args.Contains("--accessibility") || args.Contains("--results-text") || args.Contains("--connection") || args.Contains("--streaming") || args.Contains("--replace") || args.Contains("--query-history"))
{
app.ShutdownMode = ShutdownMode.OnExplicitShutdown;
app.Startup += async (_, _) =>
@@ -89,7 +92,7 @@ private static void Main(string[] args)
int count = 0;
try
{
- async Task RunFocused(string testConnection, Action testCheck) { int rep=Array.IndexOf(args,"--representative"); if(rep>=0) await RepresentativeLoadChecks.RunAsync(testConnection,args[rep+1],int.Parse(args[rep+2],System.Globalization.CultureInfo.InvariantCulture),testCheck); else if(args.Contains("--sessions")) await QuerySessionUiChecks.RunAsync(testConnection,testCheck); else if(args.Contains("--plans")) await QueryPlanUiChecks.RunAsync(testConnection,testCheck); else if(args.Contains("--export-roundtrip") || args.Contains("--export")) await ResultExportUiChecks.RunAsync(testCheck); else if(args.Contains("--query-statistics"))await QueryStatisticsDialogChecks.RunAsync(testConnection,testCheck); else if(args.Contains("--activity"))await ActivityDialogChecks.RunAsync(testConnection,testCheck); else if(args.Contains("--highlighting"))await HighlightingChecks.RunAsync(testCheck); else if(args.Contains("--global-backup"))await GlobalBackupDialogChecks.RunAsync(testConnection,testCheck); else if(args.Contains("--column-types")){await ColumnTypeDialogChecks.RunAsync(testConnection,testCheck);await EnumDialogChecks.RunAsync(testConnection,testCheck);await DomainDialogChecks.RunAsync(testConnection,testCheck);await CreateTypeDialogChecks.RunAsync(testConnection,testCheck);} else if(args.Contains("--index-maintenance")){await ConcurrentIndexStoreChecks.RunAsync(testConnection,testCheck);await ConcurrentIndexDialogChecks.RunAsync(testConnection,testCheck);} else if(args.Contains("--index-expressions")){await ExpressionIndexDialogChecks.RunAsync(testConnection,testCheck);await RangeExclusionDialogChecks.RunAsync(testConnection,testCheck);} else if(args.Contains("--partition-tables")){await PartitionTableDialogChecks.RunAsync(testConnection,testCheck);await PartitionOperationDialogChecks.RunAsync(testConnection,testCheck);} else if(args.Contains("--cascade-journal")) await CascadeJournalChecks.RunAsync(testConnection,testCheck); else if(args.Contains("--default-process")) await DefaultPrivilegeProcessChecks.RunAsync(testConnection,testCheck); else if(args.Contains("--default-cancel")) await DefaultPrivilegeCancellationChecks.RunAsync(testConnection,testCheck); else if(args.Contains("--default-ui")) await DefaultPrivilegeDialogChecks.RunAsync(testConnection,testCheck); else if(args.Contains("--default-journal")) await DefaultPrivilegeJournalChecks.RunAsync(testConnection,testCheck); else if(args.Contains("--routine-boundaries")) await RoutineBoundaryChecks.RunAsync(testConnection,testCheck); else if(args.Contains("--routine-process")) await PrivilegeCommitProcessChecks.RunAsync(testConnection,testCheck,true); else if(args.Contains("--routine-privileges")) await RoutinePrivilegeDialogChecks.RunAsync(testConnection,testCheck); else if(args.Contains("--backup-formats")) await BackupFormatDialogChecks.RunAsync(testConnection,testCheck); else if (args.Contains("--identity-columns")) { await IdentityColumnDialogChecks.RunAsync(testConnection,testCheck); await IdentityColumnOperationDialogChecks.RunAsync(testConnection,testCheck); await IdentityColumnCancellationChecks.RunAsync(testConnection,testCheck); } else if (args.Contains("--restore-ownership")) await RestoreOwnershipDialogChecks.RunAsync(testConnection,testCheck); else if (args.Contains("--privilege-journal")) { await PrivilegeCommitStoreChecks.RunAsync(testConnection,testCheck); await PrivilegeCommitProcessChecks.RunAsync(testConnection,testCheck); } else if (args.Contains("--maintain-privilege")) await MaintainPrivilegeDialogChecks.RunAsync(testConnection,testCheck); else if (args.Contains("--privilege-commit")) await PrivilegeCommitDialogChecks.RunAsync(testConnection,testCheck); else if (args.Contains("--unicode-password")) await UnicodePasswordDialogChecks.RunAsync(testConnection,testCheck); else if (args.Contains("--server-tls")) await ServerTlsDialogChecks.RunAsync(testConnection,testCheck); else if (args.Contains("--ai-settings")) await AiSettingsChecks.RunAsync(testCheck); else if (args.Contains("--schema-context")) await AiQueryChecks.RunAsync(testConnection,testCheck); else if (args.Contains("--sql-links")) await SqlLinkChecks.RunAsync(testCheck); else if (args.Contains("--editor-factors")) await EditorFactorChecks.RunAsync(testCheck, args.Contains("unicode") ? "unicode" : args.Contains("links") ? "links" : args.Contains("short") ? "short" : "long-line"); else if (args.Contains("--snapshots")) await QueryTextSnapshotChecks.RunAsync(testCheck); else if (args.Contains("--row-names")) await DataGridRowNameChecks.RunAsync(testCheck); else if (args.Contains("--membership-options")) await MembershipOptionDialogChecks.RunAsync(testConnection,testCheck); else if (args.Contains("--column-privileges")) await ColumnPrivilegeDialogChecks.RunAsync(testConnection,testCheck); else if (args.Contains("--privilege-cancellation")) await PrivilegeCancellationDialogChecks.RunAsync(testConnection,testCheck); else if (args.Contains("--sequence-privileges")) await SequencePrivilegeDialogChecks.RunAsync(testConnection,testCheck); else if (args.Contains("--privilege-commands")) { await PrivilegeCommandDialogChecks.RunAsync(testConnection,testCheck); await CascadePrivilegeDialogChecks.RunAsync(testConnection,testCheck); await GrantorAuthorityDialogChecks.RunAsync(testConnection,testCheck); await PrivilegeIdentityDialogChecks.RunAsync(testConnection,testCheck); } else if (args.Contains("--effective-privileges")) await EffectivePrivilegeDialogChecks.RunAsync(testConnection,testCheck); else if (args.Contains("--copy")) { await ResultCopyChecks.RunAsync(testConnection,testCheck); await QueryResultCopyChecks.RunAsync(testConnection,testCheck); } else if (args.Contains("--sequence-owner")) { await SequenceAttachChecks.RunAsync(testConnection,testCheck); await SequenceApplyCancellationChecks.RunAsync(testConnection,testCheck); } else if (args.Contains("--sequence-state")) { await SequenceRestartChecks.RunAsync(testConnection, testCheck); await SequenceDetachChecks.RunAsync(testConnection, testCheck); } else if (args.Contains("--open-long-line")) await SqlOpenLoadChecks.RunAsync(testCheck, "long-line"); else if (args.Contains("--open-large")) await SqlOpenLoadChecks.RunAsync(testCheck, "near-16MiB"); else if (args.Contains("--open-load")) await SqlOpenLoadChecks.RunAsync(testCheck); else if (args.Contains("--recovery-save")) await RecoverySaveMeasurementChecks.RunAsync(testCheck); else if (args.Contains("--recovery-load")) await RecoveryLoadChecks.RunAsync(testCheck); else if (args.Contains("--recovery")) await QueryRecoveryChecks.RunAsync(testCheck); else if (args.Contains("--files")) await SqlFileChecks.RunAsync(testCheck); else if (args.Contains("--properties")) { await ObjectPropertyChecks.RunAsync(testConnection, testCheck); await InspectOnOpenChecks.RunAsync(testConnection, testCheck); await TablePropertiesEditChecks.RunAsync(testConnection, testCheck); } else if (args.Contains("--explorer")) { await ExplorerExperienceChecks.RunAsync(testConnection, testCheck); await FilterSettingsChecks.RunAsync(testConnection, testCheck); await ExplorerToolbarChecks.RunAsync(testCheck); await ExplorerDisconnectChecks.RunAsync(testConnection, testCheck); await ExplorerMenuChecks.RunAsync(testConnection, testCheck); await LocalServiceChecks.RunAsync(testConnection, testCheck); await RegisteredServersChecks.RunAsync(testConnection, testCheck); await ReportViewChecks.RunAsync(testConnection, testCheck); var contextWindow = new MainWindow { ShowInTaskbar = false }; contextWindow.Show(); try { await ExplorerContextChecks.RunAsync(contextWindow, testConnection, testCheck); } finally { contextWindow.Close(); } } else if (args.Contains("--reports")) await ReportViewChecks.RunAsync(testConnection, testCheck); else if (args.Contains("--table-design")) await TableDesignerChecks.RunAsync(testConnection, testCheck); else if (args.Contains("--diagrams")) await DatabaseDiagramChecks.RunAsync(testConnection, testCheck); else if (args.Contains("--results-text")) await ResultsDestinationChecks.RunAsync(testConnection, testCheck); else if (args.Contains("--replace")) await ReplaceChecks.RunAsync(testCheck); else if (args.Contains("--streaming")) await QueryStreamingChecks.RunAsync(testConnection, testCheck); else if (args.Contains("--connection")) { await ConnectionExperienceChecks.RunAsync(testConnection, testCheck); await ConnectDialogStateChecks.RunAsync(testCheck); await WindowPlacementChecks.RunAsync(testCheck); } else if (args.Contains("--accessibility")) await AdministrativeAccessibilityChecks.RunAsync(testConnection, testCheck); else if (args.Contains("--sequences")) await SequenceChecks.RunAsync(testConnection, testCheck); else await AdvancedIndexChecks.RunAsync(testConnection, testCheck); }
+ async Task RunFocused(string testConnection, Action testCheck) { int rep=Array.IndexOf(args,"--representative"); if(rep>=0) await RepresentativeLoadChecks.RunAsync(testConnection,args[rep+1],int.Parse(args[rep+2],System.Globalization.CultureInfo.InvariantCulture),testCheck); else if(args.Contains("--sessions")) await QuerySessionUiChecks.RunAsync(testConnection,testCheck); else if(args.Contains("--plans")) await QueryPlanUiChecks.RunAsync(testConnection,testCheck); else if(args.Contains("--export-roundtrip") || args.Contains("--export")) await ResultExportUiChecks.RunAsync(testCheck); else if(args.Contains("--query-statistics"))await QueryStatisticsDialogChecks.RunAsync(testConnection,testCheck); else if(args.Contains("--activity"))await ActivityDialogChecks.RunAsync(testConnection,testCheck); else if(args.Contains("--highlighting"))await HighlightingChecks.RunAsync(testCheck); else if(args.Contains("--global-backup"))await GlobalBackupDialogChecks.RunAsync(testConnection,testCheck); else if(args.Contains("--column-types")){await ColumnTypeDialogChecks.RunAsync(testConnection,testCheck);await EnumDialogChecks.RunAsync(testConnection,testCheck);await DomainDialogChecks.RunAsync(testConnection,testCheck);await CreateTypeDialogChecks.RunAsync(testConnection,testCheck);} else if(args.Contains("--index-maintenance")){await ConcurrentIndexStoreChecks.RunAsync(testConnection,testCheck);await ConcurrentIndexDialogChecks.RunAsync(testConnection,testCheck);} else if(args.Contains("--index-expressions")){await ExpressionIndexDialogChecks.RunAsync(testConnection,testCheck);await RangeExclusionDialogChecks.RunAsync(testConnection,testCheck);} else if(args.Contains("--partition-tables")){await PartitionTableDialogChecks.RunAsync(testConnection,testCheck);await PartitionOperationDialogChecks.RunAsync(testConnection,testCheck);} else if(args.Contains("--cascade-journal")) await CascadeJournalChecks.RunAsync(testConnection,testCheck); else if(args.Contains("--default-process")) await DefaultPrivilegeProcessChecks.RunAsync(testConnection,testCheck); else if(args.Contains("--default-cancel")) await DefaultPrivilegeCancellationChecks.RunAsync(testConnection,testCheck); else if(args.Contains("--default-ui")) await DefaultPrivilegeDialogChecks.RunAsync(testConnection,testCheck); else if(args.Contains("--default-journal")) await DefaultPrivilegeJournalChecks.RunAsync(testConnection,testCheck); else if(args.Contains("--routine-boundaries")) await RoutineBoundaryChecks.RunAsync(testConnection,testCheck); else if(args.Contains("--routine-process")) await PrivilegeCommitProcessChecks.RunAsync(testConnection,testCheck,true); else if(args.Contains("--routine-privileges")) await RoutinePrivilegeDialogChecks.RunAsync(testConnection,testCheck); else if(args.Contains("--backup-formats")) await BackupFormatDialogChecks.RunAsync(testConnection,testCheck); else if (args.Contains("--identity-columns")) { await IdentityColumnDialogChecks.RunAsync(testConnection,testCheck); await IdentityColumnOperationDialogChecks.RunAsync(testConnection,testCheck); await IdentityColumnCancellationChecks.RunAsync(testConnection,testCheck); } else if (args.Contains("--restore-ownership")) await RestoreOwnershipDialogChecks.RunAsync(testConnection,testCheck); else if (args.Contains("--privilege-journal")) { await PrivilegeCommitStoreChecks.RunAsync(testConnection,testCheck); await PrivilegeCommitProcessChecks.RunAsync(testConnection,testCheck); } else if (args.Contains("--maintain-privilege")) await MaintainPrivilegeDialogChecks.RunAsync(testConnection,testCheck); else if (args.Contains("--privilege-commit")) await PrivilegeCommitDialogChecks.RunAsync(testConnection,testCheck); else if (args.Contains("--unicode-password")) await UnicodePasswordDialogChecks.RunAsync(testConnection,testCheck); else if (args.Contains("--server-tls")) await ServerTlsDialogChecks.RunAsync(testConnection,testCheck); else if (args.Contains("--ai-settings")) await AiSettingsChecks.RunAsync(testCheck); else if (args.Contains("--schema-context")) await AiQueryChecks.RunAsync(testConnection,testCheck); else if (args.Contains("--sql-links")) await SqlLinkChecks.RunAsync(testCheck); else if (args.Contains("--editor-factors")) await EditorFactorChecks.RunAsync(testCheck, args.Contains("unicode") ? "unicode" : args.Contains("links") ? "links" : args.Contains("short") ? "short" : "long-line"); else if (args.Contains("--snapshots")) await QueryTextSnapshotChecks.RunAsync(testCheck); else if (args.Contains("--row-names")) await DataGridRowNameChecks.RunAsync(testCheck); else if (args.Contains("--membership-options")) await MembershipOptionDialogChecks.RunAsync(testConnection,testCheck); else if (args.Contains("--column-privileges")) await ColumnPrivilegeDialogChecks.RunAsync(testConnection,testCheck); else if (args.Contains("--privilege-cancellation")) await PrivilegeCancellationDialogChecks.RunAsync(testConnection,testCheck); else if (args.Contains("--sequence-privileges")) await SequencePrivilegeDialogChecks.RunAsync(testConnection,testCheck); else if (args.Contains("--privilege-commands")) { await PrivilegeCommandDialogChecks.RunAsync(testConnection,testCheck); await CascadePrivilegeDialogChecks.RunAsync(testConnection,testCheck); await GrantorAuthorityDialogChecks.RunAsync(testConnection,testCheck); await PrivilegeIdentityDialogChecks.RunAsync(testConnection,testCheck); } else if (args.Contains("--effective-privileges")) await EffectivePrivilegeDialogChecks.RunAsync(testConnection,testCheck); else if (args.Contains("--copy")) { await ResultCopyChecks.RunAsync(testConnection,testCheck); await QueryResultCopyChecks.RunAsync(testConnection,testCheck); } else if (args.Contains("--sequence-owner")) { await SequenceAttachChecks.RunAsync(testConnection,testCheck); await SequenceApplyCancellationChecks.RunAsync(testConnection,testCheck); } else if (args.Contains("--sequence-state")) { await SequenceRestartChecks.RunAsync(testConnection, testCheck); await SequenceDetachChecks.RunAsync(testConnection, testCheck); } else if (args.Contains("--open-long-line")) await SqlOpenLoadChecks.RunAsync(testCheck, "long-line"); else if (args.Contains("--open-large")) await SqlOpenLoadChecks.RunAsync(testCheck, "near-16MiB"); else if (args.Contains("--open-load")) await SqlOpenLoadChecks.RunAsync(testCheck); else if (args.Contains("--recovery-save")) await RecoverySaveMeasurementChecks.RunAsync(testCheck); else if (args.Contains("--recovery-load")) await RecoveryLoadChecks.RunAsync(testCheck); else if (args.Contains("--recovery")) await QueryRecoveryChecks.RunAsync(testCheck); else if (args.Contains("--files")) await SqlFileChecks.RunAsync(testCheck); else if (args.Contains("--properties")) { await ObjectPropertyChecks.RunAsync(testConnection, testCheck); await InspectOnOpenChecks.RunAsync(testConnection, testCheck); await TablePropertiesEditChecks.RunAsync(testConnection, testCheck); } else if (args.Contains("--explorer")) { await ExplorerExperienceChecks.RunAsync(testConnection, testCheck); await FilterSettingsChecks.RunAsync(testConnection, testCheck); await ExplorerToolbarChecks.RunAsync(testCheck); await ExplorerDisconnectChecks.RunAsync(testConnection, testCheck); await ExplorerMenuChecks.RunAsync(testConnection, testCheck); await LocalServiceChecks.RunAsync(testConnection, testCheck); await RegisteredServersChecks.RunAsync(testConnection, testCheck); await ReportViewChecks.RunAsync(testConnection, testCheck); var contextWindow = new MainWindow { ShowInTaskbar = false }; contextWindow.Show(); try { await ExplorerContextChecks.RunAsync(contextWindow, testConnection, testCheck); } finally { contextWindow.Close(); } } else if (args.Contains("--reports")) await ReportViewChecks.RunAsync(testConnection, testCheck); else if (args.Contains("--table-design")) await TableDesignerChecks.RunAsync(testConnection, testCheck); else if (args.Contains("--diagrams")) await DatabaseDiagramChecks.RunAsync(testConnection, testCheck); else if (args.Contains("--results-text")) await ResultsDestinationChecks.RunAsync(testConnection, testCheck); else if (args.Contains("--query-history")) await QueryHistoryChecks.RunAsync(testConnection, testCheck); else if (args.Contains("--replace")) await ReplaceChecks.RunAsync(testCheck); else if (args.Contains("--streaming")) await QueryStreamingChecks.RunAsync(testConnection, testCheck); else if (args.Contains("--connection")) { await ConnectionExperienceChecks.RunAsync(testConnection, testCheck); await ConnectDialogStateChecks.RunAsync(testCheck); await WindowPlacementChecks.RunAsync(testCheck); } else if (args.Contains("--accessibility")) await AdministrativeAccessibilityChecks.RunAsync(testConnection, testCheck); else if (args.Contains("--sequences")) await SequenceChecks.RunAsync(testConnection, testCheck); else await AdvancedIndexChecks.RunAsync(testConnection, testCheck); }
await RunFocused($"Host=127.0.0.1;Port={TestClusterEndpoint.Port};Database=postgres;Username=pgms_test;SSL Mode=Disable;Pooling=false", (ok, text) =>
{
if (!ok) throw new Exception("FAILED: " + text);
@@ -754,6 +757,7 @@ void Check(bool condition, string name)
await TableDesignerChecks.RunAsync(fixtureConnection, Check);
await DatabaseDiagramChecks.RunAsync(fixtureConnection, Check);
await ResultsDestinationChecks.RunAsync(fixtureConnection, Check);
+ await QueryHistoryChecks.RunAsync(fixtureConnection, Check);
await ObjectPropertyChecks.RunAsync(fixtureConnection, Check);
await InspectOnOpenChecks.RunAsync(fixtureConnection, Check);
await TablePropertiesEditChecks.RunAsync(fixtureConnection, Check);
diff --git a/tests/PGMS.DesktopTests/QueryHistoryChecks.cs b/tests/PGMS.DesktopTests/QueryHistoryChecks.cs
new file mode 100644
index 0000000..a44734d
--- /dev/null
+++ b/tests/PGMS.DesktopTests/QueryHistoryChecks.cs
@@ -0,0 +1,432 @@
+using System.IO;
+using System.Security.Cryptography;
+using System.Text;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Controls.Primitives;
+using System.Windows.Threading;
+using PGMS.Desktop;
+
+/// PGMS-043 (M1060): Query History store, recording from query tabs on the isolated cluster, profile exclusion, options and the window.
+internal static class QueryHistoryChecks
+{
+ private static async Task Idle() => await Dispatcher.Yield(DispatcherPriority.ApplicationIdle);
+ private static async Task Until(Func done) { for (int i = 0; i < 300 && !done(); i++) await Task.Delay(50); }
+ /// Image of a window for visual review (artifacts are not committed).
+ private static void Capture(Window window, string name)
+ {
+ string dir = Path.GetFullPath(Path.Combine("artifacts", "acceptance", "M1060")); Directory.CreateDirectory(dir);
+ var content = (FrameworkElement)window.Content;
+ var bitmap = new System.Windows.Media.Imaging.RenderTargetBitmap((int)Math.Ceiling(content.ActualWidth), (int)Math.Ceiling(content.ActualHeight), 96, 96, System.Windows.Media.PixelFormats.Pbgra32);
+ bitmap.Render(content);
+ var encoder = new System.Windows.Media.Imaging.PngBitmapEncoder(); encoder.Frames.Add(System.Windows.Media.Imaging.BitmapFrame.Create(bitmap));
+ using var file = File.Create(Path.Combine(dir, name + ".png")); encoder.Save(file);
+ }
+ private static string NewRoot(string name) => Path.GetFullPath(Path.Combine("artifacts", "history-tests", name + "-" + Guid.NewGuid().ToString("N")));
+
+ private static QueryHistoryEntry Entry(string sql, DateTime started, string database = "postgres", QueryHistoryOutcome outcome = QueryHistoryOutcome.Succeeded, QueryHistorySettings? settings = null, int port = 55439, QueryHistoryOrigin origin = QueryHistoryOrigin.Typed)
+ => new QueryHistoryCapture(sql, "127.0.0.1", port, database, "pgms_test", started, TimeSpan.FromMilliseconds(12), origin, outcome, 1, null,
+ outcome == QueryHistoryOutcome.Failed ? "42P01" : null, outcome == QueryHistoryOutcome.Failed ? "relation does not exist" : null).ToEntry(settings ?? new());
+
+ public static async Task RunAsync(string connection, Action check)
+ {
+ check(Environment.GetEnvironmentVariable("PGMS_HISTORY_DIRECTORY") is { Length: > 0 } dir && !dir.Contains(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), StringComparison.OrdinalIgnoreCase),
+ "Desktop tests record Query History in a temporary folder, never in the user's profile");
+ StoreChecks(check);
+ await RecorderChecks(check);
+ await TabChecks(connection, check);
+ await WindowChecks(connection, check);
+ await DialogChecks(check);
+ }
+
+ private static void StoreChecks(Action check)
+ {
+ string root = NewRoot("store");
+ var store = new QueryHistoryStore(root);
+ var now = DateTime.UtcNow;
+ var first = Entry("SELECT 'synthetic secret ação 😀'\r\n FROM pg_class;", now.AddMinutes(-3));
+ store.Append(first, new(), now);
+ var snapshot = store.ReadAll();
+ check(snapshot.Entries.Count == 1 && snapshot.Entries[0] == first && snapshot.UnreadableFiles == 0, "Query History roundtrips an execution with DPAPI");
+ string segment = Directory.GetFiles(root, "*.qhs").Single();
+ check(!Encoding.UTF8.GetString(File.ReadAllBytes(segment)).Contains("synthetic secret") && !Encoding.Unicode.GetString(File.ReadAllBytes(segment)).Contains("synthetic secret"), "Query History writes no plaintext SQL to disk");
+ check(Directory.GetFiles(root, "*.partial").Length == 0, "Query History leaves no staging file");
+
+ // Deduplication: whitespace runs collapse, case is kept, server/port/database separate.
+ store.Append(Entry("SELECT 'synthetic secret ação 😀' FROM pg_class;", now.AddMinutes(-2)), new(), now);
+ store.Append(Entry(" SELECT 'synthetic secret ação 😀'\tFROM\npg_class; ", now.AddMinutes(-1)), new(), now);
+ store.Append(Entry("select 'synthetic secret ação 😀' from pg_class;", now), new(), now);
+ store.Append(Entry("SELECT 'synthetic secret ação 😀' FROM pg_class;", now, database: "other"), new(), now);
+ var groups = QueryHistoryGroup.Build(store.ReadAll());
+ check(groups.Count == 3 && groups.Single(g => g.Key.Database == "postgres" && g.Latest.Sql.TrimStart().StartsWith("SELECT", StringComparison.Ordinal)).Executions.Count == 3,
+ "Executions group by normalized SQL per server and database; case differences stay separate");
+ check(groups.Single(g => g.Executions.Count == 3).Executions.Select(e => e.StartedUtc).SequenceEqual(groups.Single(g => g.Executions.Count == 3).Executions.Select(e => e.StartedUtc).OrderByDescending(t => t)),
+ "A group lists its executions newest first");
+
+ // Size limit per entry.
+ string big = string.Concat(Enumerable.Repeat("SELECT 1; -- 😀 padding\n", 4000));
+ var cut = Entry(big, now);
+ check(cut.Sql.Length <= 65536 && cut.OriginalLength == big.Length && cut.Truncated && cut.Hash == QueryHistoryText.HashOf(big) && !char.IsHighSurrogate(cut.Sql[^1]),
+ "SQL over 64 KiB is cut without splitting a surrogate pair, marked, and grouped by the hash of the whole text");
+ check(cut.ReopenText.EndsWith("when it was recorded.", StringComparison.Ordinal) && cut.ReopenText.Contains("-- PGMS Query History: this text was cut", StringComparison.Ordinal), "A cut entry reopens with a comment that says so");
+ var smaller = Entry(big, now, settings: new(MaxSqlKiB: 1));
+ check(smaller.Sql.Length <= 1024, "The per-entry size limit follows the setting");
+ var lone = Entry("SELECT '\ud800';", now);
+ check(lone.Sql == "SELECT '�';", "A lone surrogate is stored as U+FFFD instead of failing the record");
+
+ // Tamper, copy under another name, unknown version and oversize are refused and kept.
+ byte[] bytes = File.ReadAllBytes(segment); bytes[bytes.Length / 2] ^= 0xFF; File.WriteAllBytes(segment, bytes);
+ var damaged = store.ReadAll();
+ check(damaged.UnreadableFiles == 1 && File.ReadAllBytes(segment).SequenceEqual(bytes) && damaged.Entries.Count == 0, "A damaged history file is reported and kept byte for byte");
+ store.Append(Entry("SELECT 'after damage';", now), new(), now);
+ check(store.ReadAll().Entries.Count == 1 && Directory.GetFiles(root, "*.qhs").Length == 2, "Recording continues in a new file after a damaged one");
+ string good = Directory.GetFiles(root, "*.qhs").Single(f => f != segment);
+ string copied = Path.Combine(root, "20000101T000000000-" + Guid.NewGuid().ToString("N") + ".qhs");
+ File.Copy(good, copied);
+ check(store.ReadAll().UnreadableFiles == 2 && File.Exists(copied), "A history file copied under another name does not decrypt (entropy bound to its name) and is kept");
+ string otherRoot = NewRoot("copy");
+ Directory.CreateDirectory(otherRoot);
+ string unknown = Path.Combine(otherRoot, "20000101T000000000-" + Guid.NewGuid().ToString("N") + ".qhs");
+ File.WriteAllBytes(unknown, ProtectedData.Protect(Encoding.UTF8.GetBytes("{\"Version\":99,\"Name\":\"" + Path.GetFileName(unknown) + "\",\"Entries\":[]}"),
+ [.. "PGMS query history 1\0"u8.ToArray(), .. Encoding.UTF8.GetBytes(Path.GetFileName(unknown).ToLowerInvariant())], DataProtectionScope.CurrentUser));
+ string oversize = Path.Combine(otherRoot, "20000101T000000001-" + Guid.NewGuid().ToString("N") + ".qhs");
+ using (var stream = File.Create(oversize)) stream.SetLength(3 * 1024 * 1024 + 1L);
+ string plain = Path.Combine(otherRoot, "20000101T000000002-" + Guid.NewGuid().ToString("N") + ".qhs");
+ File.WriteAllText(plain, "{\"Version\":1}");
+ var refused = new QueryHistoryStore(otherRoot).ReadAll();
+ check(refused.UnreadableFiles == 3 && File.Exists(unknown) && File.Exists(oversize) && File.Exists(plain), "Unknown version, oversize and unencrypted history files are refused and kept");
+ check(new QueryHistoryStore(root).Clear() == 1 && Directory.GetFiles(root, "*.qhs").Length == 0, "Clear History removes every history file, readable or not");
+
+ // Retention by count, keeping the newest execution of a pinned query.
+ string countRoot = NewRoot("count");
+ var counted = new QueryHistoryStore(countRoot);
+ var limits = new QueryHistorySettings(MaxExecutions: 100);
+ var pinnedOld = Entry("SELECT 'pinned';", now.AddDays(-5));
+ counted.Append(pinnedOld, limits, now);
+ counted.SetPinned(pinnedOld.Key, true);
+ for (int i = 0; i < 100; i++) counted.Append(Entry($"SELECT {i};", now.AddMinutes(-200 + i)), limits, now);
+ var afterCount = counted.ReadAll();
+ check(afterCount.Entries.Count == 95 && afterCount.Entries.Any(e => e.Id == pinnedOld.Id) && afterCount.Entries.Any(e => e.Sql == "SELECT 99;") && !afterCount.Entries.Any(e => e.Sql == "SELECT 0;"),
+ "Past the count limit the oldest executions go until 95% is left; the pinned query's newest execution stays");
+ check(afterCount.Pins.Contains(pinnedOld.Key), "Pins persist encrypted");
+
+ // Retention by age (startup), again keeping the pinned one.
+ string ageRoot = NewRoot("age");
+ var aged = new QueryHistoryStore(ageRoot);
+ var pinnedAncient = Entry("SELECT 'pinned ancient';", now.AddDays(-400));
+ aged.Append(pinnedAncient, new(), now.AddDays(-400));
+ aged.SetPinned(pinnedAncient.Key, true);
+ aged.Append(Entry("SELECT 'ancient';", now.AddDays(-100)), new(), now.AddDays(-100));
+ aged.Append(Entry("SELECT 'recent';", now.AddDays(-10)), new(), now.AddDays(-10));
+ int removed = new QueryHistoryStore(ageRoot).Prune(new(RetentionDays: 90), now);
+ var afterAge = aged.ReadAll();
+ check(removed == 1 && afterAge.Entries.Select(e => e.Sql).OrderBy(s => s).SequenceEqual(new[] { "SELECT 'pinned ancient';", "SELECT 'recent';" }), "Startup cleanup removes executions older than the retention days, except a pinned query's newest");
+
+ // Delete one execution, unpin.
+ var toDelete = afterAge.Entries.Single(e => e.Sql == "SELECT 'recent';");
+ check(aged.Delete([toDelete.Id]) == 1 && aged.ReadAll().Entries.All(e => e.Id != toDelete.Id), "Delete removes exactly the chosen execution");
+ aged.SetPinned(pinnedAncient.Key, false);
+ check(aged.ReadAll().Pins.Count == 0, "Unpin is saved");
+
+ // Segments roll over near 1 MiB and all stay readable.
+ string rollRoot = NewRoot("roll");
+ var roll = new QueryHistoryStore(rollRoot);
+ string chunk = new('x', 200000);
+ for (int i = 0; i < 8; i++) roll.Append(Entry($"SELECT '{i}{chunk}';", now.AddSeconds(i), settings: new(MaxSqlKiB: 256)), new(MaxSqlKiB: 256), now);
+ var rolled = roll.ReadAll();
+ check(Directory.GetFiles(rollRoot, "*.qhs").Length >= 2 && rolled.Entries.Count == 8 && rolled.UnreadableFiles == 0, "History files roll over near 1 MiB of JSON and every one stays readable");
+
+ // Two PGMS processes of the same user: the lock file keeps every execution.
+ string sharedRoot = NewRoot("shared");
+ var a = new QueryHistoryStore(sharedRoot); var b = new QueryHistoryStore(sharedRoot);
+ Parallel.For(0, 60, i => (i % 2 == 0 ? a : b).Append(Entry($"SELECT {i} AS concurrent;", now.AddMilliseconds(i)), new(), now));
+ check(a.ReadAll().Entries.Count == 60, "Concurrent writers sharing the folder lose no execution");
+ using (new FileStream(Path.Combine(sharedRoot, QueryHistoryStore.LockFileName), FileMode.Open, FileAccess.ReadWrite, FileShare.None))
+ {
+ var waiting = new QueryHistoryStore(sharedRoot) { LockTimeout = TimeSpan.FromMilliseconds(200) };
+ bool timedOut = false;
+ try { waiting.Append(Entry("SELECT 'locked';", now), new(), now); } catch (IOException) { timedOut = true; }
+ check(timedOut, "A write gives up with an error when another process holds the history lock too long");
+ }
+ }
+
+ private static async Task RecorderChecks(Action check)
+ {
+ string root = NewRoot("recorder");
+ var settings = new QueryHistorySettings();
+ bool exclude = false;
+ var warnings = new List();
+ var recorder = new QueryHistoryRecorder(new QueryHistoryStore(root), () => settings, (_, _) => exclude);
+ recorder.Warning += warnings.Add;
+ var capture = new QueryHistoryCapture("SELECT 1;", "127.0.0.1", 55439, "postgres", "pgms_test", DateTime.UtcNow, TimeSpan.FromMilliseconds(3), QueryHistoryOrigin.Typed, QueryHistoryOutcome.Succeeded, 1);
+ check(await recorder.RecordAsync(capture) && (await recorder.ReadAsync()).Entries.Count == 1, "The recorder writes an execution on its queue");
+ settings = new(Enabled: false);
+ check(!await recorder.RecordAsync(capture) && (await recorder.ReadAsync()).Entries.Count == 1, "Recording turned off in Options writes nothing");
+ settings = new(); exclude = true;
+ check(!await recorder.RecordAsync(capture) && (await recorder.ReadAsync()).Entries.Count == 1, "A server excluded by a profile writes nothing");
+
+ string profiles = Path.Combine(NewRoot("profiles"), "connections.json");
+ var profileStore = new ConnectionProfileStore(profiles);
+ profileStore.Save(new ConnectionProfile(Guid.NewGuid(), "Production (synthetic)", "127.0.0.1", 55439, "postgres", "pgms_test", "Disable", DisableQueryHistory: true));
+ profileStore.Save(new ConnectionProfile(Guid.NewGuid(), "Other port", "127.0.0.1", 55440, "postgres", "pgms_test", "Disable"));
+ check(QueryHistoryRecorder.ProfileExcludes(profiles, "127.0.0.1", 55439) && !QueryHistoryRecorder.ProfileExcludes(profiles, "127.0.0.1", 55440) && QueryHistoryRecorder.ProfileExcludes(profiles, "127.0.0.1".ToUpperInvariant(), 55439),
+ "A profile with Do not record query history excludes its host and port only");
+ check(new ConnectionProfileStore(profiles).Load().Single(p => p.Port == 55439).DisableQueryHistory, "The history option is saved with the profile");
+ File.WriteAllText(profiles, "{ not json");
+ var guarded = new QueryHistoryRecorder(new QueryHistoryStore(root), () => new(), (s, p) => QueryHistoryRecorder.ProfileExcludes(profiles, s, p));
+ var guardedWarnings = new List(); guarded.Warning += guardedWarnings.Add;
+ check(!await guarded.RecordAsync(capture) && guardedWarnings.Single().Contains("profiles could not be read", StringComparison.Ordinal), "Unreadable profiles record nothing and say why");
+
+ string blocked = Path.Combine(NewRoot("blocked"), "file-not-folder");
+ Directory.CreateDirectory(Path.GetDirectoryName(blocked)!); File.WriteAllText(blocked, "x");
+ var failing = new QueryHistoryRecorder(new QueryHistoryStore(blocked), () => new(), (_, _) => false);
+ var failures = new List(); failing.Warning += failures.Add;
+ check(!await failing.RecordAsync(capture) && failures.Single().Contains("the query itself was not affected", StringComparison.Ordinal), "A write failure becomes a warning, not an exception");
+ check(warnings.Count == 0, "Normal recording raises no warning");
+ }
+
+ private static async Task TabChecks(string connection, Action check)
+ {
+ var window = new MainWindow { ShowInTaskbar = false, Width = 1100, Height = 800 }; window.Show();
+ try
+ {
+ await Idle();
+ var history = window.QueryHistory;
+ check(history.Store.DirectoryPath == Path.GetFullPath(Environment.GetEnvironmentVariable("PGMS_HISTORY_DIRECTORY")!), "The main window records into the configured history folder");
+ await history.ClearAsync();
+ async Task Last() { await history.Io.RunAsync(() => true); return (await history.ReadAsync()).Entries.OrderBy(e => e.StartedUtc).LastOrDefault(); }
+ int Count() => history.Store.ReadAll().Entries.Count;
+
+ var query = await window.OpenQueryTextAsync(connection, "-- typed below");
+ query.Editor.Text = "SELECT 1 AS a UNION ALL SELECT 2;";
+ await query.ExecuteAsync();
+ var ok = await Last();
+ int clusterPort = new Npgsql.NpgsqlConnectionStringBuilder(connection).Port;
+ check(ok is { Outcome: QueryHistoryOutcome.Succeeded, Server: "127.0.0.1", Database: "postgres", User: "pgms_test", RowsReturned: 2, Origin: QueryHistoryOrigin.Typed, Sql: "SELECT 1 AS a UNION ALL SELECT 2;" }
+ && ok.Port == clusterPort && ok.StartedUtc <= DateTime.UtcNow && ok.StartedUtc > DateTime.UtcNow.AddMinutes(-2) && ok.DurationMilliseconds >= 0,
+ "F5 in the grid records SQL, server, database, user, start (UTC), duration, rows, success and typed origin");
+
+ query.Editor.Text = "SELECT * FROM pgms_history_missing_table;";
+ await query.ExecuteAsync();
+ var failed = await Last();
+ check(failed is { Outcome: QueryHistoryOutcome.Failed, SqlState: "42P01" } && failed.Error?.Contains("pgms_history_missing_table", StringComparison.Ordinal) == true, "An error is recorded with SQLSTATE and message");
+
+ query.Editor.Text = "CREATE TEMP TABLE pgms_history_t(x int); INSERT INTO pgms_history_t VALUES (1), (2), (3);";
+ await query.ExecuteAsync();
+ var affected = await Last();
+ check(affected is { Outcome: QueryHistoryOutcome.Succeeded, RowsAffected: 3 }, "Rows affected are recorded for DML");
+
+ query.Editor.Text = "SELECT pg_sleep(30);";
+ var run = query.ExecuteAsync();
+ await Until(() => query.IsBusy); await Task.Delay(400);
+ query.Cancel(); await run;
+ var canceled = await Last();
+ check(canceled is { Outcome: QueryHistoryOutcome.Canceled, Sql: "SELECT pg_sleep(30);" }, "A canceled execution is recorded as canceled");
+
+ int before = Count();
+ query.Editor.Text = "SELECT $1;";
+ await query.ExecuteAsync(); await history.Io.RunAsync(() => true);
+ check(Count() == before, "SQL refused before sending (unbound placeholder) is not recorded");
+
+ query.ResultsDestination = ResultsDestination.Text;
+ query.Editor.Text = "SELECT 10 AS t; SELECT 20 AS u;";
+ await query.ExecuteAsync();
+ var text = await Last();
+ check(text is { Outcome: QueryHistoryOutcome.Succeeded, RowsReturned: 2, Sql: "SELECT 10 AS t; SELECT 20 AS u;" }, "Results to Text executions are recorded too");
+ query.ResultsDestination = ResultsDestination.Grid;
+
+ // Origins.
+ query.Editor.Text = "";
+ query.InsertGeneratedSql("SELECT 'from ai' AS origin;");
+ await query.ExecuteAsync();
+ check((await Last())?.Origin == QueryHistoryOrigin.Ai, "SQL inserted by the AI assistant and run unchanged is recorded with origin AI");
+ query.Editor.Text = "";
+ query.InsertDesignedSql("SELECT 'from designer' AS origin");
+ await query.ExecuteAsync();
+ check((await Last())?.Origin == QueryHistoryOrigin.QueryDesigner, "SQL written by Query Designer is recorded with origin Query Designer");
+ query.Editor.Text = "SELECT 'from designer, edited' AS origin";
+ await query.ExecuteAsync();
+ check((await Last())?.Origin == QueryHistoryOrigin.Typed, "Once edited, generated SQL counts as typed");
+ var script = await window.OpenQueryTextAsync(connection, "SELECT 'from a script' AS origin;");
+ await script.ExecuteAsync();
+ check((await Last())?.Origin == QueryHistoryOrigin.Other, "SQL a tool opened in a new tab (Script as, Activity Monitor) is recorded with origin Other");
+ query.RecordDesignerExecution("SELECT 'designer pane';", DateTime.UtcNow, TimeSpan.FromMilliseconds(5), null, false, 1);
+ check((await Last()) is { Origin: QueryHistoryOrigin.QueryDesigner, Database: "postgres", Sql: "SELECT 'designer pane';" }, "Execute SQL in the Query Designer is recorded with the tab's server and database");
+
+ // The write never delays or fails the query.
+ string slowRoot = NewRoot("slow");
+ var slow = new QueryHistoryRecorder(new QueryHistoryStore(slowRoot), () => new(), (_, _) => false);
+ Task? pending = null;
+ query.HistorySink = c => pending = slow.RecordAsync(c);
+ Directory.CreateDirectory(slowRoot);
+ var gate = new FileStream(Path.Combine(slowRoot, QueryHistoryStore.LockFileName), FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None);
+ var clock = System.Diagnostics.Stopwatch.StartNew();
+ query.Editor.Text = "SELECT 'while history is locked';";
+ await query.ExecuteAsync();
+ clock.Stop();
+ bool stillWaiting = pending is { IsCompleted: false };
+ gate.Dispose();
+ check(stillWaiting && clock.ElapsedMilliseconds < 5000 && query.QueryStatus.Text.StartsWith("Query completed", StringComparison.Ordinal), $"The query completes while Query History waits for its file ({clock.ElapsedMilliseconds} ms)");
+ check(pending is not null && await pending, "The waiting execution is written once the file is free");
+ string brokenRoot = Path.Combine(NewRoot("broken"), "file");
+ Directory.CreateDirectory(Path.GetDirectoryName(brokenRoot)!); File.WriteAllText(brokenRoot, "x");
+ var broken = new QueryHistoryRecorder(new QueryHistoryStore(brokenRoot), () => new(), (_, _) => false);
+ var brokenWarnings = new List(); broken.Warning += brokenWarnings.Add;
+ Task? brokenTask = null;
+ query.HistorySink = c => brokenTask = broken.RecordAsync(c);
+ query.Editor.Text = "SELECT 'history cannot be written';";
+ await query.ExecuteAsync();
+ check(query.QueryStatus.Text.StartsWith("Query completed", StringComparison.Ordinal) && brokenTask is not null && !await brokenTask && brokenWarnings.Count == 1, "A history write failure leaves the query completed and only warns");
+ query.HistorySink = c => _ = history.RecordAsync(c);
+ }
+ finally { foreach (var tab in window.QueryDocuments.ToArray()) tab.IsDirty = false; window.Close(); await Idle(); }
+ }
+
+ private static async Task WindowChecks(string connection, Action check)
+ {
+ var window = new MainWindow { ShowInTaskbar = false, Width = 1100, Height = 800 }; window.Show();
+ try
+ {
+ await Idle();
+ var menu = (MenuItem)window.FindName("QueryHistoryMenu");
+ check(menu.Header as string == "Query _History…" && menu.InputGestureText == "Ctrl+Alt+H" && menu.Parent is MenuItem { Header: "_View" }, "View ▸ Query History… with Ctrl+Alt+H");
+ var history = window.QueryHistory;
+ await history.ClearAsync();
+ var now = DateTime.UtcNow;
+ var store = history.Store;
+ store.Append(Entry("SELECT 'weekly report' AS r;", now.AddDays(-10)), new(), now);
+ store.Append(Entry("SELECT 'weekly report' AS r;", now.AddDays(-1)), new(), now);
+ store.Append(Entry("SELECT 'weekly report' AS r;", now.AddMinutes(-5)), new(), now);
+ store.Append(Entry("SELECT * FROM missing;", now.AddMinutes(-4), outcome: QueryHistoryOutcome.Failed), new(), now);
+ store.Append(Entry("SELECT 'other database';", now.AddMinutes(-3), database: "pgms_other"), new(), now);
+ var view = window.ShowQueryHistory();
+ check(ReferenceEquals(window.ShowQueryHistory(), view), "Asking again brings the same Query History window to the front");
+ await view.Loading; await Idle();
+ check(view.Rows.Count == 3 && view.Rows.All(r => r.IsGroup) && view.Rows[0].Sql == "SELECT 'other database';", "One row per distinct query, newest first");
+ var weekly = view.Rows.Single(r => r.Sql.Contains("weekly", StringComparison.Ordinal));
+ check(weekly.Executions == "3" && weekly.CanExpand, "A query row shows how many times it ran");
+ view.SetExpanded(weekly, true); await Idle();
+ check(view.Rows.Count == 6 && view.Rows.Count(r => !r.IsGroup) == 3, "Expanding a query lists its individual executions");
+ view.Entries.SelectedItem = view.Rows.First(r => !r.IsGroup);
+ await Idle(); Capture(view, "query-history");
+ check(view.SqlText.Text == "SELECT 'weekly report' AS r;" && view.Details.Text.Contains("Origin: Typed", StringComparison.Ordinal), "The lower pane shows the full SQL of the selected execution");
+ var failedRow = view.Rows.Single(r => r.IsGroup && r.Entry.Outcome == QueryHistoryOutcome.Failed);
+ check(failedRow.Result == "Error 42P01", "The result column shows the SQLSTATE of an error");
+
+ view.TextFilter.Text = "MISSING"; await Idle();
+ check(view.Rows.Count == 1 && view.Rows[0].Entry.Outcome == QueryHistoryOutcome.Failed, "The text filter matches the SQL ignoring case");
+ view.TextFilter.Text = "";
+ view.ConnectionFilter.SelectedItem = view.ConnectionFilter.Items.OfType().Single(i => (string?)i.Tag == "127.0.0.1:55439 / pgms_other"); await Idle();
+ check(view.Rows.Count == 1 && view.Rows[0].Database == "pgms_other", "The connection filter keeps one server and database");
+ view.ConnectionFilter.SelectedIndex = 0;
+ view.PeriodFilter.SelectedIndex = 1; await Idle();
+ var today = view.Rows.Where(r => r.IsGroup && r.Sql.Contains("weekly", StringComparison.Ordinal)).ToList();
+ check(today.Count == 1 && view.Rows.Count(r => !r.IsGroup) <= 1, "The period filter hides executions outside it");
+ view.PeriodFilter.SelectedIndex = 0; await Idle();
+
+ view.Entries.SelectedItem = view.Rows.Single(r => r.IsGroup && r.Sql.Contains("weekly", StringComparison.Ordinal));
+ await view.TogglePinAsync(); await Idle();
+ check(view.Rows[0].Group.Pinned && view.Rows[0].PinMark == "★" && (await history.ReadAsync()).Pins.Count == 1, "Pin puts the query first and is saved");
+ view.PinnedOnly.IsChecked = true; await Idle();
+ check(view.Rows.Where(r => r.IsGroup).All(r => r.Group.Pinned) && view.Rows.Any(), "Pinned only shows pinned queries");
+ view.PinnedOnly.IsChecked = false; await Idle();
+
+ string? copied = null; view.WriteClipboard = t => copied = t;
+ view.Entries.SelectedItem = view.Rows.Single(r => r.IsGroup && r.Database == "pgms_other");
+ view.CopySql();
+ check(copied == "SELECT 'other database';", "Copy SQL writes the selected SQL");
+
+ // Reopen: same server/port/user open in a tab, database switched; nothing executed.
+ var open = await window.OpenQueryTextAsync(connection, "SELECT 'keep this session';");
+ await using (var admin = new Npgsql.NpgsqlConnection(connection))
+ {
+ await admin.OpenAsync();
+ await using (var dropOld = new Npgsql.NpgsqlCommand("DROP DATABASE IF EXISTS pgms_history_reopen WITH (FORCE)", admin)) await dropOld.ExecuteNonQueryAsync();
+ await using var create = new Npgsql.NpgsqlCommand("CREATE DATABASE pgms_history_reopen", admin);
+ await create.ExecuteNonQueryAsync();
+ }
+ int clusterPort = new Npgsql.NpgsqlConnectionStringBuilder(connection).Port;
+ var reopenEntry = Entry("SELECT current_database();", now, database: "pgms_history_reopen", port: clusterPort);
+ int before = history.Store.ReadAll().Entries.Count;
+ var reopened = await window.OpenHistoryEntryAsync(reopenEntry);
+ await history.Io.RunAsync(() => true);
+ check(reopened.IsConnected && reopened.Database == "pgms_history_reopen" && reopened.EditorText == "SELECT current_database();" && history.Store.ReadAll().Entries.Count == before,
+ "Open in New Query reuses an open connection to that server and user, switches to the entry's database, and executes nothing");
+ var stranger = await window.OpenHistoryEntryAsync(Entry("SELECT 'elsewhere';", now, port: 55498));
+ check(!stranger.HasSession && stranger.EditorText == "SELECT 'elsewhere';", "Without an open connection to that server the SQL opens disconnected");
+ var cutEntry = Entry(new string('y', 70000), now, port: clusterPort);
+ var cutTab = await window.OpenHistoryEntryAsync(cutEntry);
+ check(cutTab.EditorText.EndsWith("when it was recorded.", StringComparison.Ordinal), "A cut entry reopens with the cut marked in a comment");
+ QueryHistoryEntry? asked = null;
+ var spy = new QueryHistoryWindow(history, e => { asked = e; return Task.CompletedTask; }) { ShowInTaskbar = false };
+ spy.Show(); await spy.Loading; await Idle();
+ spy.Entries.SelectedItem = spy.Rows.Single(r => r.IsGroup && r.Database == "pgms_other");
+ await spy.OpenAsync();
+ check(asked?.Database == "pgms_other", "Open in New Query hands the selected execution to the main window");
+
+ spy.Confirm = _ => false;
+ await spy.DeleteAsync();
+ check(spy.Rows.Any(r => r.Database == "pgms_other"), "Delete asks first and does nothing on No");
+ spy.Confirm = _ => true;
+ spy.Entries.SelectedItem = spy.Rows.Single(r => r.IsGroup && r.Database == "pgms_other");
+ await spy.DeleteAsync();
+ check(!spy.Rows.Any(r => r.Database == "pgms_other") && history.Store.ReadAll().Entries.All(e => e.Database != "pgms_other"), "Delete on a query row removes all its executions");
+ await spy.ClearAsync();
+ check(spy.Rows.Count == 0 && history.Store.ReadAll().Entries.Count == 0, "Clear History in the window empties Query History");
+ spy.Close();
+ await using (var admin = new Npgsql.NpgsqlConnection(connection))
+ {
+ await admin.OpenAsync();
+ await using var drop = new Npgsql.NpgsqlCommand("DROP DATABASE IF EXISTS pgms_history_reopen WITH (FORCE);", admin);
+ await drop.ExecuteNonQueryAsync();
+ }
+ }
+ finally { foreach (var tab in window.QueryDocuments.ToArray()) tab.IsDirty = false; window.Close(); await Idle(); }
+ }
+
+ private static async Task DialogChecks(Action check)
+ {
+ string state = NewRoot("dialogs"); Directory.CreateDirectory(state);
+ var options = new EnvironmentOptions(Path.Combine(state, "environment.json"));
+ check(options.Load().History == new QueryHistorySettings(true, 90, 10000, 64), "Defaults: on, 90 days, 10,000 executions, 64 KiB");
+ int cleared = 0;
+ var dialog = new OptionsDialog(options, () => { cleared++; return Task.FromResult(7); }) { ShowInTaskbar = false };
+ dialog.Show(); await Idle();
+ ((TreeViewItem)dialog.FindName("HistoryPage")).IsSelected = true; await Idle();
+ check(((FrameworkElement)dialog.FindName("HistoryPanel")).IsVisible, "Tools ▸ Options ▸ Query Execution ▸ Query History shows its page");
+ Capture(dialog, "options-history");
+ ((TextBox)dialog.FindName("HistoryDaysBox")).Text = "0";
+ dialog.Accept(dialog, new RoutedEventArgs());
+ check(dialog.IsVisible && ((TextBlock)dialog.FindName("HistoryError")).Visibility == Visibility.Visible && options.Load().History.RetentionDays == 90, "An out-of-range retention is refused and nothing is saved");
+ dialog.ConfirmClearHistory = () => true;
+ await dialog.ClearHistoryAsync();
+ check(cleared == 1 && ((TextBlock)dialog.FindName("ClearHistoryStatus")).Text.Contains("7 execution", StringComparison.Ordinal), "Clear History in Options clears at once and reports the count");
+ ((TextBox)dialog.FindName("HistoryDaysBox")).Text = "30";
+ ((TextBox)dialog.FindName("HistoryCountBox")).Text = "500";
+ ((TextBox)dialog.FindName("HistorySizeBox")).Text = "16";
+ ((CheckBox)dialog.FindName("HistoryEnabledBox")).IsChecked = false;
+ dialog.Accept(dialog, new RoutedEventArgs()); await Idle();
+ check(options.Load().History == new QueryHistorySettings(false, 30, 500, 16) && !dialog.IsVisible, "OK saves the Query History settings");
+
+ string profiles = Path.Combine(state, "connections.json");
+ var connect = new ConnectionDialog(profilePath: profiles) { ShowInTaskbar = false };
+ connect.Show(); await Idle();
+ var box = (CheckBox)connect.FindName("DisableHistory");
+ check(box.IsChecked == false, "Do not record query history starts off");
+ ((TextBox)connect.FindName("ProfileName")).Text = "Production (synthetic)";
+ ((TextBox)connect.FindName("Host")).Text = "127.0.0.1";
+ ((TextBox)connect.FindName("Port")).Text = "55439";
+ box.IsChecked = true;
+ await Idle(); Capture(connect, "connect-history");
+ ((Button)connect.FindName("SaveProfileButton")).RaiseEvent(new RoutedEventArgs(ButtonBase.ClickEvent)); await Idle();
+ var saved = new ConnectionProfileStore(profiles).Load().Single();
+ check(saved.DisableQueryHistory, "Save Profile stores Do not record query history");
+ ((Button)connect.FindName("NewProfileButton")).RaiseEvent(new RoutedEventArgs(ButtonBase.ClickEvent)); await Idle();
+ check(box.IsChecked == false, "A new profile starts with history recording on");
+ ((ComboBox)connect.FindName("Profiles")).SelectedItem = ((ComboBox)connect.FindName("Profiles")).Items.OfType().Single(); await Idle();
+ check(box.IsChecked == true, "Selecting the profile shows its history option");
+ connect.Close();
+ File.WriteAllText(Path.Combine(state, "old.json"), "{\"Version\":1,\"Profiles\":[{\"Id\":\"" + Guid.NewGuid() + "\",\"Name\":\"old\",\"Host\":\"h\",\"Port\":5432,\"Database\":\"d\",\"Username\":\"u\",\"SslMode\":\"Prefer\"}]}");
+ check(!new ConnectionProfileStore(Path.Combine(state, "old.json")).Load().Single().DisableQueryHistory, "Profiles saved before this option record history");
+ }
+}