diff --git a/docs/en/release-notes.md b/docs/en/release-notes.md
index 2799ced..f3816bd 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.
+
+- **Indexes in database diagrams.** Each table of a diagram has an **Indexes** section, closed by default, with one row per index when opened: method (GIN, GiST, SP-GiST, BRIN, Hash), key columns or expressions, operator class when not the default (such as `gin_trgm_ops`), `INCLUDE`, UNIQUE, EXCLUDE, partial and invalid, with the predicate and the full `CREATE INDEX` in the tooltip. A warning marks a table with a foreign key that no index serves and opens **Find Unindexed Foreign Keys…**, which also arrives in this change: on a database in Object Explorer it lists such keys from the catalog and creates the indexes from a reviewed script, concurrently or in one transaction. **Show Indexes** and **Refresh** are in the diagram menu. Each save writes the indexes of each table into the diagram definition (format 2) for tools and AI agents that read it; older diagrams open unchanged. Read only: indexes are still created in **Indexes/Keys…**. SSMS 22.11 shows no indexes in a diagram. 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..d4f9027 100644
--- a/docs/en/user-guide.md
+++ b/docs/en/user-guide.md
@@ -20,6 +20,8 @@ Identity and sequence operations distinguish configured START from RESTART. A re
In Object Explorer, right-click **Database Diagrams** under a database and choose **New Database Diagram**. The first time, PGMS asks to create its support objects (schema `pgms`). Add tables with **Add Table** or **Add Related Tables**, drag a column onto another to create a foreign key, and use **Arrange Tables** to lay them out. Saving shows the SQL script of any change to the database for review.
+**Indexes.** Each table shows an **Indexes** section below its columns, closed by default so diagrams laid out before keep their spacing: the header gives the count and the methods other than b-tree (for example `Indexes (3) · GIN`), and its tooltip lists every index. Click the header to show one row per index: name, method when it is not b-tree (Hash, GIN, GiST, SP-GiST, BRIN), key columns or expressions, the operator class when it is not the default (such as `gin_trgm_ops`, `jsonb_path_ops` or `text_pattern_ops`), `INCLUDE` columns, and UNIQUE, EXCLUDE, partial or invalid. The row tooltip has the predicate of a partial index and the full `CREATE INDEX`. A gold key marks the primary key, a silver key a unique or exclusion constraint, and a tree a plain index. A warning triangle in the title marks a table with a foreign key that no index serves, by the same rule as **Find Unindexed Foreign Keys…**; click it, or use the item of the table menu, to open that window. **Show Indexes** in the diagram menu turns the sections and the warning off and on for that diagram, and **Refresh** reads the tables and their indexes again. The section is read only: create or change indexes in **Indexes/Keys…** (double-click an index row) and the other dialogs. Every save writes the indexes of each table, read from the catalog inside the save transaction, into the diagram definition (`pgms.diagrams.definition`, format 2), so tools and AI agents that read the diagram see them; changes made only in the database do not mark the diagram as changed and reach the definition on its next save. Diagrams saved by earlier versions open as they were and are written in the new format by their next save. SSMS 22.11 shows no indexes in a diagram; this section is a PGMS addition.
+
## Query Designer
**Query ▸ Design Query in Editor** (`Ctrl+Shift+Q`) opens the statement under the cursor, or the selection, in the Diagram, Criteria and SQL panes. **OK** replaces that statement in the editor; nothing is executed. On an empty line it starts empty and inserts at the cursor. `UNION` and `WITH` open only in the SQL pane.
diff --git a/docs/pt-BR/release-notes.md b/docs/pt-BR/release-notes.md
index a235807..649e1cf 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.
+
+- **Índices nos diagramas de banco.** Cada tabela de um diagrama tem uma seção **Indexes**, recolhida por padrão, com uma linha por índice quando aberta: método (GIN, GiST, SP-GiST, BRIN, Hash), colunas ou expressões da chave, classe de operadores quando não é a padrão (como `gin_trgm_ops`), `INCLUDE`, UNIQUE, EXCLUDE, parcial e inválido, com o predicado e o `CREATE INDEX` completo na dica. Um aviso marca a tabela com chave estrangeira que nenhum índice atende e abre **Find Unindexed Foreign Keys…**, que também chega nesta mudança: num banco do Object Explorer, lista essas chaves pelo catálogo e cria os índices a partir de um script revisado, de forma concorrente ou em uma transação. **Show Indexes** e **Refresh** ficam no menu do diagrama. Cada gravação escreve os índices de cada tabela na definição do diagrama (formato 2) para ferramentas e agentes de IA que a leem; diagramas antigos abrem sem mudança. Só leitura: índices continuam sendo criados em **Indexes/Keys…**. O SSMS 22.11 não mostra índices no diagrama. 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..c167b35 100644
--- a/docs/pt-BR/user-guide.md
+++ b/docs/pt-BR/user-guide.md
@@ -20,6 +20,8 @@ Identity e sequences distinguem START configurado de RESTART. Reiniciar pode rep
No Object Explorer, clique com o botão direito em **Database Diagrams**, dentro de um banco, e escolha **New Database Diagram**. Na primeira vez, o PGMS pede para criar seus objetos de suporte (schema `pgms`). Adicione tabelas com **Add Table** ou **Add Related Tables**, arraste uma coluna sobre outra para criar uma chave estrangeira e use **Arrange Tables** para organizar. Ao salvar, o PGMS mostra o script SQL de qualquer alteração no banco para revisão.
+**Índices.** Cada tabela mostra uma seção **Indexes** abaixo das colunas, recolhida por padrão para que diagramas já organizados mantenham o espaçamento: o cabeçalho traz a quantidade e os métodos que não são b-tree (por exemplo `Indexes (3) · GIN`), e a dica dele lista todos os índices. Clique no cabeçalho para ver uma linha por índice: nome, método quando não é b-tree (Hash, GIN, GiST, SP-GiST, BRIN), colunas ou expressões da chave, a classe de operadores quando não é a padrão (como `gin_trgm_ops`, `jsonb_path_ops` ou `text_pattern_ops`), colunas `INCLUDE` e UNIQUE, EXCLUDE, parcial (partial) ou inválido (invalid). A dica da linha traz o predicado de um índice parcial e o `CREATE INDEX` completo. Uma chave dourada marca a chave primária, uma chave prateada uma constraint UNIQUE ou de exclusão, e uma árvore um índice comum. Um triângulo de aviso no título marca a tabela com chave estrangeira que nenhum índice atende, pela mesma regra de **Find Unindexed Foreign Keys…**; clique nele, ou use o item do menu da tabela, para abrir essa janela. **Show Indexes**, no menu do diagrama, liga e desliga as seções e o aviso naquele diagrama, e **Refresh** lê de novo as tabelas e seus índices. A seção é só leitura: crie ou altere índices em **Indexes/Keys…** (duplo clique numa linha de índice) e nos outros diálogos. Toda gravação escreve os índices de cada tabela, lidos do catálogo dentro da transação da gravação, na definição do diagrama (`pgms.diagrams.definition`, formato 2), para que ferramentas e agentes de IA que leem o diagrama os vejam; mudanças feitas só no banco não marcam o diagrama como alterado e chegam à definição na próxima gravação. Diagramas salvos por versões anteriores abrem como estavam e passam ao formato novo na próxima gravação. O SSMS 22.11 não mostra índices no diagrama; esta seção é um acréscimo do PGMS.
+
## Query Designer
**Query ▸ Design Query in Editor** (`Ctrl+Shift+Q`) abre a instrução sob o cursor, ou a seleção, nos painéis Diagram, Criteria e SQL. **OK** substitui essa instrução no editor; nada é executado. Numa linha vazia, abre vazio e insere no cursor. `UNION` e `WITH` abrem apenas no painel SQL.
diff --git a/src/PGMS.Desktop/ChangeScriptDialog.xaml.cs b/src/PGMS.Desktop/ChangeScriptDialog.xaml.cs
index cbead4b..6b3c4f6 100644
--- a/src/PGMS.Desktop/ChangeScriptDialog.xaml.cs
+++ b/src/PGMS.Desktop/ChangeScriptDialog.xaml.cs
@@ -8,11 +8,12 @@ namespace PGMS.Desktop;
/// The SQL a designer is about to run. Save requires this review (decision of 06/11/2026); Generate Change Script opens it without Apply, like the SSMS 22.11 Save Change Script.
public partial class ChangeScriptDialog : Window
{
- public ChangeScriptDialog(TableDesignChange change, bool canApply)
+ /// Replaces the first line when Apply does not run the script in one transaction.
+ public ChangeScriptDialog(TableDesignChange change, bool canApply, string? intro = null)
{
InitializeComponent();
Script.Text = change.Sql.Replace("\n", Environment.NewLine, StringComparison.Ordinal);
- Intro.Text = canApply ? "Review the change script. Apply runs it in one transaction." : "Do you want to save this change script to a text file?";
+ Intro.Text = intro ?? (canApply ? "Review the change script. Apply runs it in one transaction." : "Do you want to save this change script to a text file?");
ApplyButton.Visibility = canApply ? Visibility.Visible : Visibility.Collapsed;
if (!canApply) CancelButton.Content = "_Close";
if (change.Warnings.Count > 0) { Warnings.Text = string.Join(Environment.NewLine, change.Warnings); Warnings.Visibility = Visibility.Visible; }
diff --git a/src/PGMS.Desktop/DatabaseDiagramModel.cs b/src/PGMS.Desktop/DatabaseDiagramModel.cs
index d83bdf3..73d5e49 100644
--- a/src/PGMS.Desktop/DatabaseDiagramModel.cs
+++ b/src/PGMS.Desktop/DatabaseDiagramModel.cs
@@ -51,6 +51,10 @@ public sealed class DiagramTableModel
public double Y { get; set; }
public DiagramTableView View { get; set; }
public List CustomColumns { get; } = [.. DiagramColumnFields.Standard];
+ /// The indexes of the table as the catalog has them (PGMS-044): what the Indexes section shows. Pending changes of Indexes/Keys are not here until saved.
+ public DiagramTableIndexes Catalog { get; set; } = DiagramTableIndexes.Empty;
+ /// The Indexes section is open; it starts closed (decisão do usuário, 11/11/2026).
+ public bool IndexesExpanded { get; set; }
public DiagramTableModel(TableDesign design, double x, double y) { Original = design; X = x; Y = y; Reset(design); }
@@ -63,14 +67,15 @@ public sealed class DiagramTableModel
/// Made with New Table in this diagram and not saved yet.
public bool IsNew => Original.Oid == 0;
public IReadOnlyList Fields => View switch { DiagramTableView.Standard => DiagramColumnFields.Standard, DiagramTableView.Custom => CustomColumns, _ => [] };
- /// The rows of the current Table View. Keys shows the columns of the primary key, the unique keys and the foreign keys (inferred).
+ /// The rows of the current Table View. Keys shows the columns of the keys and of every index, as SSMS 22.11 does (pilotagem de 11/11/2026,
+ /// docs/MARCO-M1061.md: a column used only in the WHERE of a filtered index stays out); the columns of the foreign keys are inferred.
public IReadOnlyList VisibleColumns
{
get
{
if (View == DiagramTableView.NameOnly) return [];
if (View != DiagramTableView.Keys) return Columns;
- var keys = Indexes.Where(i => i.Kind != DesignIndexKind.Index).SelectMany(i => i.Columns.Select(c => c.Name)).Concat(ForeignKeys.SelectMany(k => k.Columns)).ToHashSet(StringComparer.Ordinal);
+ var keys = Indexes.SelectMany(i => i.Columns.Select(c => c.Name)).Concat(ForeignKeys.SelectMany(k => k.Columns)).ToHashSet(StringComparer.Ordinal);
return Columns.Where(c => keys.Contains(c.Name)).ToArray();
}
}
diff --git a/src/PGMS.Desktop/DatabaseDiagramView.Editing.cs b/src/PGMS.Desktop/DatabaseDiagramView.Editing.cs
index a3f7688..1252df9 100644
--- a/src/PGMS.Desktop/DatabaseDiagramView.Editing.cs
+++ b/src/PGMS.Desktop/DatabaseDiagramView.Editing.cs
@@ -48,6 +48,7 @@ private static void ApplyPlacement(DiagramTableModel table, DiagramTablePlacemen
{
if (Enum.TryParse(placement.View, out var view)) table.View = view;
if (placement.CustomColumns is { } custom) { table.CustomColumns.Clear(); table.CustomColumns.AddRange(custom.Where(DiagramColumnFields.All.Contains)); }
+ table.IndexesExpanded = placement.IndexesExpanded;
}
private Rect[] ContentRects() => tables.Where(boxes.ContainsKey).Select(Bounds).Concat(annotations.Where(noteBoxes.ContainsKey).Select(NoteBounds)).ToArray();
diff --git a/src/PGMS.Desktop/DatabaseDiagramView.Indexes.cs b/src/PGMS.Desktop/DatabaseDiagramView.Indexes.cs
new file mode 100644
index 0000000..758a4de
--- /dev/null
+++ b/src/PGMS.Desktop/DatabaseDiagramView.Indexes.cs
@@ -0,0 +1,156 @@
+using System.Windows;
+using System.Windows.Automation;
+using System.Windows.Controls;
+using System.Windows.Documents;
+using System.Windows.Input;
+using System.Windows.Media;
+using System.Windows.Shapes;
+using PGMS.PostgreSql;
+using Path = System.Windows.Shapes.Path;
+
+namespace PGMS.Desktop;
+
+/// Indexes in the diagram (PGMS-044, docs/MARCO-M1061.md). SSMS 22.11 shows no index in a diagram table (pilotagem de 11/11/2026): the section,
+/// the method label and the warning for foreign keys without an index are PGMS additions; the glyphs follow the Object Explorer of SSMS (a key for the
+/// primary key, another key for unique, a tree for an index). Read only: indexes are created and changed in Indexes/Keys and the other dialogs.
+public partial class DatabaseDiagramView
+{
+ private static readonly Brush IndexHeaderBrush = Frozen("#F3F3F3"), IndexDetailBrush = Frozen("#6A6A6A"), InvalidBrush = Frozen("#A1260D"), WarningFill = Frozen("#F2C200"), WarningInk = Frozen("#5C4500"),
+ SilverKey = FrozenGradient("#F4F4F4", "#9A9A9A"), TreeInk = Frozen("#424242");
+
+ /// Show Indexes of the diagram menu: the Indexes section and the warning of foreign keys without an index, on by default, kept by diagram.
+ public bool IndexesShown => showIndexes;
+
+ /// Find Unindexed Foreign Keys… from the warning of a table or the table menu; the main window opens the window on this database.
+ public event Action? UnindexedForeignKeysRequested;
+
+ public void ToggleIndexes() { showIndexes = !showIndexes; Rebuild(); Touch(); }
+
+ /// Opens or closes the Indexes section of a table; the table keeps its place, so the tables below may be covered until moved or arranged.
+ public void ToggleIndexSection(DiagramTableModel table) { table.IndexesExpanded = !table.IndexesExpanded; Rebuild(); Touch(); }
+
+ public void RequestUnindexedForeignKeys() => UnindexedForeignKeysRequested?.Invoke();
+
+ /// Refresh of the diagram menu: reads the tables without pending changes and the indexes of every table again.
+ public Task RefreshAsync() => RefreshTablesAsync();
+
+ private bool ShowsIndexSection(DiagramTableModel table) => showIndexes && !table.IsNew && table.View != DiagramTableView.NameOnly;
+ private bool ShowsWarning(DiagramTableModel table) => showIndexes && !table.IsNew && table.Catalog.UnindexedForeignKeys.Count > 0;
+
+ /// The title of a table: the caption and, when a foreign key of the table has no index, the warning on the right.
+ private UIElement TitleContent(DiagramTableModel table, TextBlock caption)
+ {
+ if (!ShowsWarning(table)) return caption;
+ var panel = new DockPanel();
+ var warning = WarningGlyph(table);
+ DockPanel.SetDock(warning, Dock.Right);
+ panel.Children.Add(warning); panel.Children.Add(caption);
+ return panel;
+ }
+
+ internal static string UnindexedText(DiagramTableIndexes catalog)
+ {
+ var lines = new List { catalog.UnindexedForeignKeys.Count == 1 ? "A foreign key of this table has no index:" : "Foreign keys of this table have no index:" };
+ foreach (var key in catalog.UnindexedForeignKeys)
+ lines.Add($" {key.Name} ({string.Join(", ", key.Columns)})" + (key.NearIndexes.Count > 0 ? " — " + string.Join("; ", key.NearIndexes) : ""));
+ lines.Add("");
+ lines.Add("Deleting or updating a row of the referenced table reads this table to check the key.");
+ lines.Add("Click to open Find Unindexed Foreign Keys.");
+ return string.Join("\n", lines);
+ }
+
+ private FrameworkElement WarningGlyph(DiagramTableModel table)
+ {
+ var canvas = new Canvas { Width = 14, Height = 13, Margin = new Thickness(6, 0, 0, 0), VerticalAlignment = VerticalAlignment.Center, Background = Brushes.Transparent, Cursor = Cursors.Hand };
+ canvas.Children.Add(new Path { Data = Geometry.Parse("M7,0.5 L13.5,12.5 L0.5,12.5 Z"), Fill = WarningFill, Stroke = WarningInk, StrokeThickness = 0.9, StrokeLineJoin = PenLineJoin.Round, IsHitTestVisible = false });
+ canvas.Children.Add(new Path { Data = Geometry.Parse("M6.2,4 L7.8,4 L7.5,8.6 L6.5,8.6 Z M6.2,9.6 L7.8,9.6 L7.8,11.1 L6.2,11.1 Z"), Fill = WarningInk, IsHitTestVisible = false });
+ canvas.Tag = new DiagramHit(table, null, DiagramHitPart.Warning);
+ canvas.ToolTip = UnindexedText(table.Catalog);
+ ToolTipService.SetShowDuration(canvas, 60000);
+ AutomationProperties.SetName(canvas, "Foreign keys without an index");
+ AutomationProperties.SetHelpText(canvas, UnindexedText(table.Catalog));
+ return canvas;
+ }
+
+ private static string MethodsText(IEnumerable indexes) => string.Join(", ", indexes.Select(i => i.MethodLabel).Where(m => m.Length > 0).Distinct(StringComparer.Ordinal));
+
+ internal static string IndexToolTip(DiagramIndex index)
+ {
+ var lines = new List { index.Name, $"{index.Kind}, {(index.Method == "btree" ? "B-tree" : index.MethodLabel)}" };
+ lines.Add("Keys: " + string.Join(", ", index.Keys.Select(k => k.Text)));
+ if (index.Keys.Any(k => k.OperatorClass is not null)) lines.Add("Operator class: " + string.Join(", ", index.Keys.Where(k => k.OperatorClass is not null).Select(k => $"{k.Expression} {k.OperatorClass}")));
+ if (index.Include.Count > 0) lines.Add("Include: " + string.Join(", ", index.Include));
+ if (index.Predicate is { } predicate) lines.Add("Partial: WHERE " + predicate);
+ if (!index.Valid) lines.Add("Invalid: a failed CREATE INDEX CONCURRENTLY left it; the planner does not use it.");
+ lines.Add("");
+ lines.Add(index.Definition);
+ return string.Join("\n", lines);
+ }
+
+ /// The Indexes section below the columns: a header that opens and closes it, then one row per index when open.
+ private void AddIndexSection(StackPanel stack, DiagramTableModel table)
+ {
+ var indexes = table.Catalog.Indexes;
+ string methods = MethodsText(indexes);
+ var header = new Grid { Height = 18, Background = IndexHeaderBrush, Cursor = Cursors.Hand, Tag = new DiagramHit(table, null, DiagramHitPart.IndexHeader) };
+ var text = new TextBlock { Margin = new Thickness(5, 0, 6, 0), VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis };
+ text.Inlines.Add(new Run((table.IndexesExpanded ? "▾ " : "▸ ") + $"Indexes ({indexes.Count})"));
+ if (methods.Length > 0) text.Inlines.Add(new Run(" · " + methods) { Foreground = IndexDetailBrush });
+ header.Children.Add(text);
+ header.ToolTip = indexes.Count == 0 ? "This table has no index." : string.Join("\n", indexes.Select(i => $"{i.Name} {i.MethodLabel}{(i.MethodLabel.Length > 0 ? " " : "")}{i.KeysText}{(i.Flags.Count > 0 ? " " + string.Join(", ", i.Flags) : "")}"))
+ + (table.IndexesExpanded ? "" : "\n\nClick to show the indexes in the table.");
+ ToolTipService.SetShowDuration(header, 60000);
+ AutomationProperties.SetName(header, $"Indexes, {indexes.Count}, {(table.IndexesExpanded ? "expanded" : "collapsed")}");
+ // Closed, the header never widens the table: the diagram keeps the size it was laid out with.
+ stack.Children.Add(table.IndexesExpanded ? header : new WidthNeutral { Child = header });
+ if (!table.IndexesExpanded) return;
+ foreach (var index in indexes)
+ {
+ var row = new Grid { Height = 18, Background = Brushes.White, Tag = new DiagramHit(table, null, DiagramHitPart.Index, index.Name), ToolTip = IndexToolTip(index) };
+ ToolTipService.SetShowDuration(row, 60000);
+ row.ColumnDefinitions.Add(new() { Width = new GridLength(18) }); row.ColumnDefinitions.Add(new());
+ row.Children.Add(new Border { Background = SelectorBrush, BorderBrush = Brushes.Gainsboro, BorderThickness = new Thickness(0, 0, 1, 0), Child = IndexGlyph(index) });
+ var line = new TextBlock { Margin = new Thickness(5, 0, 10, 0), VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis };
+ line.Inlines.Add(new Run(index.Name) { Foreground = index.Valid ? Brushes.Black : InvalidBrush });
+ if (index.MethodLabel.Length > 0) line.Inlines.Add(new Run(" " + index.MethodLabel) { FontWeight = FontWeights.SemiBold, Foreground = IndexDetailBrush });
+ line.Inlines.Add(new Run(" " + index.KeysText) { Foreground = IndexDetailBrush });
+ var flags = index.Flags.Where(f => f != "PRIMARY KEY").ToArray(); // the key glyph already says it
+ if (flags.Length > 0) line.Inlines.Add(new Run(" " + string.Join(", ", flags)) { Foreground = index.Valid ? IndexDetailBrush : InvalidBrush, FontStyle = FontStyles.Italic });
+ Grid.SetColumn(line, 1); row.Children.Add(line);
+ AutomationProperties.SetName(row, $"Index {index.Name}, {index.Kind}{(index.MethodLabel.Length > 0 ? ", " + index.MethodLabel : "")}, {index.KeysText}{(flags.Length > 0 ? ", " + string.Join(", ", flags) : "")}");
+ stack.Children.Add(row);
+ }
+ }
+
+ /// The glyph of an index row: the gold key of the primary key (as on its columns), a silver key lying down for unique and exclusion, a tree for an index.
+ internal static Canvas IndexGlyph(DiagramIndex index)
+ {
+ Canvas canvas;
+ if (index.Primary) canvas = KeyGlyph();
+ else if (index.Unique || index.Exclusion)
+ {
+ canvas = new Canvas { Width = 15, Height = 10, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center, IsHitTestVisible = false };
+ var bow = Geometry.Combine(new EllipseGeometry(new Point(4.5, 5), 4.2, 4.2), new EllipseGeometry(new Point(3.9, 5), 1.3, 1.3), GeometryCombineMode.Exclude, null);
+ canvas.Children.Add(new Path { Data = Union(bow, Rounded(8, 4, 6.2, 2.2, 0.4), Rounded(10.6, 6, 1.4, 2.4, 0.3), Rounded(12.6, 6, 1.4, 1.8, 0.3)), Fill = SilverKey, Stroke = Brushes.DimGray, StrokeThickness = 0.9 });
+ }
+ else
+ {
+ canvas = new Canvas { Width = 13, Height = 12, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center, IsHitTestVisible = false };
+ canvas.Children.Add(new Path { Data = Geometry.Parse("M4.5,0.5 H8.5 V3.5 H4.5 Z M0.5,8.5 H4.5 V11.5 H0.5 Z M8.5,8.5 H12.5 V11.5 H8.5 Z M6.5,3.5 V6 M2.5,8.5 V6 H10.5 V8.5"), Stroke = TreeInk, StrokeThickness = 1, Fill = Brushes.White });
+ }
+ AutomationProperties.SetName(canvas, index.Primary ? "Primary key index" : index.Unique || index.Exclusion ? "Unique index" : "Index");
+ return canvas;
+ }
+
+ /// Measures its child in the space it is given but asks for no width, so a long line is cut instead of widening the table.
+ private sealed class WidthNeutral : Decorator
+ {
+ protected override Size MeasureOverride(Size constraint)
+ {
+ if (Child is null) return default;
+ Child.Measure(new Size(double.IsInfinity(constraint.Width) ? 0 : constraint.Width, constraint.Height));
+ return new Size(0, Child.DesiredSize.Height);
+ }
+ protected override Size ArrangeOverride(Size arrangeSize) { Child?.Arrange(new Rect(arrangeSize)); return arrangeSize; }
+ }
+}
diff --git a/src/PGMS.Desktop/DatabaseDiagramView.xaml.cs b/src/PGMS.Desktop/DatabaseDiagramView.xaml.cs
index 4d41021..b630b82 100644
--- a/src/PGMS.Desktop/DatabaseDiagramView.xaml.cs
+++ b/src/PGMS.Desktop/DatabaseDiagramView.xaml.cs
@@ -12,7 +12,7 @@
namespace PGMS.Desktop;
-public enum DiagramHitPart { Title, Column, Selector, Body }
+public enum DiagramHitPart { Title, Column, Selector, Body, IndexHeader, Index, Warning }
/// What the mouse is over in a diagram: a part of a table box, with the column when it is a row.
public sealed record DiagramHit(DiagramTableModel Table, string? Column, DiagramHitPart Part, string? Field = null);
/// The key (Key = true) or ∞ end of a relationship line, dragged to change the side of the table it meets.
@@ -45,7 +45,7 @@ public partial class DatabaseDiagramView : UserControl
private DiagramRelation? selectedRelation;
private DiagramInfo? info;
private string name, layoutBaseline = "";
- private bool showLabels, loadedOnce, updatingZoom;
+ private bool showLabels, loadedOnce, updatingZoom, showIndexes = true;
private double zoom = 1;
private int cascade;
private CancellationTokenSource? operation;
@@ -165,11 +165,16 @@ public async Task LoadAsync()
Say("Reading diagram…", cancellable: true);
var service = new DiagramService(connection);
var diagram = await service.ReadAsync(info?.Id ?? openId!.Value, cancellation.Token);
- var (designs, missing) = await service.ReadTablesAsync(diagram.Layout.Tables, cancellation.Token);
+ var (designs, missing, indexes) = await service.ReadTablesAndIndexesAsync(diagram.Layout.Tables, [], cancellation.Token);
info = diagram.Info; name = diagram.Info.Name;
tables.Clear(); dropped.Clear(); selected.Clear(); selectedRelation = null;
var found = diagram.Layout.Tables.Where(p => !missing.Contains(p.Schema + "." + p.Name)).ToArray();
- for (int i = 0; i < designs.Count && i < found.Length; i++) { var table = new DiagramTableModel(designs[i], found[i].X, found[i].Y); ApplyPlacement(table, found[i]); tables.Add(table); }
+ for (int i = 0; i < designs.Count && i < found.Length; i++)
+ {
+ var table = new DiagramTableModel(designs[i], found[i].X, found[i].Y) { Catalog = indexes.GetValueOrDefault(designs[i].Oid) ?? DiagramTableIndexes.Empty };
+ ApplyPlacement(table, found[i]); tables.Add(table);
+ }
+ showIndexes = diagram.Layout.ShowIndexes;
annotations.Clear(); annotations.AddRange((diagram.Layout.Annotations ?? []).Select(a => new DiagramAnnotation(a.Text, a.X, a.Y)));
showPageBreaks = diagram.Layout.ShowPageBreaks;
pageSize = diagram.Layout.PageWidth > 0 && diagram.Layout.PageHeight > 0 ? new Size(diagram.Layout.PageWidth, diagram.Layout.PageHeight) : null;
@@ -180,7 +185,8 @@ public async Task LoadAsync()
Rebuild(); ApplyZoom();
// Tables dropped elsewhere leave the layout changed, so the tab shows * until the diagram is saved again.
layoutBaseline = missing.Count == 0 ? LayoutFingerprint() : "";
- Say(missing.Count == 0 ? "" : "Tables no longer in the database were removed from the diagram: " + string.Join(", ", missing) + ".");
+ string newer = diagram.Layout.Format > DiagramLayout.CurrentFormat ? $"This diagram was saved by a newer version of PGMS (format {diagram.Layout.Format}); saving it here keeps only what this version knows. " : "";
+ Say(newer + (missing.Count == 0 ? "" : "Tables no longer in the database were removed from the diagram: " + string.Join(", ", missing) + "."));
}
catch (OperationCanceledException) { Say("Reading canceled."); }
catch (Exception ex) when (ex is NpgsqlException or IOException or TimeoutException or InvalidOperationException) { Say("The diagram could not be read: " + Message(ex)); }
@@ -192,9 +198,11 @@ public async Task LoadAsync()
{
Tables = tables.Select(t => new DiagramTablePlacement(t.Schema, t.Name, Math.Round(t.X), Math.Round(t.Y))
{
- Oid = t.Original.Oid, View = t.View == DiagramTableView.ColumnNames ? null : t.View.ToString(), CustomColumns = t.CustomColumns.SequenceEqual(DiagramColumnFields.Standard) ? null : t.CustomColumns.ToArray()
+ Oid = t.Original.Oid, View = t.View == DiagramTableView.ColumnNames ? null : t.View.ToString(), CustomColumns = t.CustomColumns.SequenceEqual(DiagramColumnFields.Standard) ? null : t.CustomColumns.ToArray(),
+ IndexesExpanded = t.IndexesExpanded
}).ToArray(),
- Zoom = zoom, ShowRelationshipLabels = showLabels,
+ // The indexes are written by DiagramService.SaveAsync from the catalog, so they never make the tab look changed.
+ Zoom = zoom, ShowRelationshipLabels = showLabels, ShowIndexes = showIndexes,
Annotations = annotations.Select(a => new DiagramAnnotationPlacement(a.Text, Math.Round(a.X), Math.Round(a.Y))).ToArray(),
ShowPageBreaks = showPageBreaks, PageWidth = pageSize?.Width ?? 0, PageHeight = pageSize?.Height ?? 0,
Relationships = Relations.Where(r => manualRoutes.ContainsKey(r.Key)).Select(r =>
@@ -297,7 +305,7 @@ private void Rebuild()
{
var stack = new StackPanel();
var caption = new TextBlock { Text = TableTitle(table), FontWeight = FontWeights.SemiBold, TextTrimming = TextTrimming.CharacterEllipsis };
- var title = new Border { Background = TitleBrush, Padding = new Thickness(5, 2, 8, 3), Child = caption, Tag = new DiagramHit(table, null, DiagramHitPart.Title), Cursor = Cursors.SizeAll };
+ var title = new Border { Background = TitleBrush, Padding = new Thickness(5, 2, 8, 3), Child = TitleContent(table, caption), Tag = new DiagramHit(table, null, DiagramHitPart.Title), Cursor = Cursors.SizeAll };
stack.Children.Add(title);
var key = table.PrimaryKey.ToHashSet(StringComparer.Ordinal);
var fields = table.Fields;
@@ -323,6 +331,7 @@ private void Rebuild()
AutomationProperties.SetName(row, column.Name + (key.Contains(column.Name) ? ", primary key" : ""));
stack.Children.Add(row);
}
+ if (ShowsIndexSection(table)) AddIndexSection(stack, table);
var box = new Border { BorderBrush = FrameBrush, BorderThickness = new Thickness(FrameWidth), Background = Brushes.White, Child = stack, MinWidth = 130, MaxWidth = fields.Count > 0 ? 760 : 360, SnapsToDevicePixels = true, Tag = new DiagramHit(table, null, DiagramHitPart.Body) };
AutomationProperties.SetName(box, "Table " + table.Caption);
return (box, title, caption);
@@ -611,13 +620,18 @@ internal void Press(Point point, int clicks = 1, bool extend = false)
var selectedBefore = selectedColumnTable is { } before && selectedColumns.Count == 1 ? (before, selectedColumns.First()) : default((DiagramTableModel, string)?);
var hit = SelectAt(point, extend);
pendingCell = clicks == 1 && !extend && hit is DiagramHit { Column: { } clicked, Part: DiagramHitPart.Column } cell && selectedBefore == (cell.Table, clicked) ? cell : null;
+ // The Indexes header opens and closes on a click (a second click of a double click does nothing); the warning opens Find Unindexed Foreign Keys
+ // after the click is over, so the window never opens under a pressed button.
+ if (!extend && hit is DiagramHit { Part: DiagramHitPart.IndexHeader } header) { drag = DragMode.None; if (clicks == 1) ToggleIndexSection(header.Table); return; }
+ if (!extend && hit is DiagramHit { Part: DiagramHitPart.Warning }) { drag = DragMode.None; if (clicks == 1) Dispatcher.BeginInvoke(RequestUnindexedForeignKeys); return; }
// Double click on a line opens its properties (pedido do usuário, 08/11/2026), as Alt+Enter does.
if (clicks == 2 && DoubleClick(hit)) { drag = DragMode.None; return; }
dragStart = point; moved = false; dragOrigins.Clear(); drag = DragMode.None;
if (extend && hit is not null) return;
pressedTable = (hit as DiagramHit)?.Table;
if (hit is DiagramHit { Column: not null, Part: DiagramHitPart.Selector or DiagramHitPart.Column } column) { drag = DragMode.Column; StartColumnDrag(column, point); }
- else if (hit is DiagramHit { Part: DiagramHitPart.Title } && selected.Count > 0) { drag = DragMode.Move; foreach (var table in selected) dragOrigins[table] = new(table.X, table.Y); }
+ // An index row moves the table like its title: it is read only, so there is nothing else to drag.
+ else if (hit is DiagramHit { Part: DiagramHitPart.Title or DiagramHitPart.Index } && selected.Count > 0) { drag = DragMode.Move; foreach (var table in selected) dragOrigins[table] = new(table.X, table.Y); }
else if (hit is DiagramRelation relation && StartLineDrag(relation, point, end?.Key)) drag = DragMode.Line;
else if (hit is DiagramAnnotation note) { drag = DragMode.Annotation; noteOrigin = new(note.X, note.Y); }
else if (hit is null) { drag = DragMode.Marquee; marqueeBase = selected.ToArray(); }
@@ -632,6 +646,11 @@ internal bool DoubleClick(object? hit)
switch (hit)
{
case DiagramRelation relation: selected.Clear(); selectedRelation = relation; break;
+ case DiagramHit { Part: DiagramHitPart.Index, Field: { } index } row:
+ // A double click on an index opens Indexes/Keys on it, the dialog where indexes are changed.
+ selectedRelation = null; selected.Clear(); selected.Add(row.Table); RefreshTitles(); RefreshLineSelection();
+ ShowIndexes(row.Table, index);
+ return true;
case DiagramHit table: selectedRelation = null; selected.Clear(); selected.Add(table.Table); break;
case DiagramAnnotation note: BeginNoteEdit(note); return true;
default: return false;
@@ -864,8 +883,11 @@ MenuItem Add(string header, Action click, string name, string? icon = null, bool
Add("Add Related Tables", () => _ = AddRelatedTablesAsync(), "DiagramAddRelated", enabled: !IsBusy);
Separator();
Add("Relationships…", () => ShowRelationships(table), "DiagramRelationships", "DesignerIconRelationships", single);
- Add("Indexes/Keys…", () => ShowIndexes(table), "DiagramIndexes", "DesignerIconIndexes", single);
+ string? onIndex = target is DiagramHit { Part: DiagramHitPart.Index, Field: var clickedIndex } ? clickedIndex : null;
+ Add("Indexes/Keys…", () => ShowIndexes(table, onIndex), "DiagramIndexes", "DesignerIconIndexes", single);
Add("Check Constraints…", () => ShowChecks(table), "DiagramChecks", "DesignerIconChecks", single);
+ // Only on a table with the warning (PGMS-044); not in the SSMS menu.
+ if (ShowsWarning(table)) Add("Find Unindexed Foreign Keys…", RequestUnindexedForeignKeys, "DiagramUnindexedForeignKeys");
Separator();
Add("Generate Change Script…", ShowChangeScript, "DiagramScript", "DesignerIconScript", HasSchemaChanges);
Separator();
@@ -890,6 +912,8 @@ MenuItem Add(string header, Action click, string name, string? icon = null, bool
Separator();
Add("Select All", SelectAll, "DiagramSelectAll", enabled: tables.Count > 0, gesture: "Ctrl+A");
Add("Show Relationship Labels", ToggleLabels, "DiagramShowLabels").IsChecked = showLabels;
+ // Show Indexes and Refresh are PGMS additions (PGMS-044): SSMS shows no index in a diagram and has no Refresh in this menu.
+ Add("Show Indexes", ToggleIndexes, "DiagramShowIndexes").IsChecked = showIndexes;
Separator();
Add("Arrange Tables", ArrangeTables, "DiagramArrange", enabled: tables.Count > 0);
var zoomMenu = new MenuItem { Header = "Zoom", Name = "DiagramZoom" };
@@ -905,6 +929,8 @@ MenuItem Add(string header, Action click, string name, string? icon = null, bool
Add("Recalculate Page Breaks", RecalculatePageBreaks, "DiagramRecalculatePageBreaks");
Separator();
Add("Copy Diagram to Clipboard", () => CopyDiagram(), "DiagramCopy", enabled: ContentRects().Length > 0);
+ Separator();
+ Add("Refresh", () => _ = RefreshAsync(), "DiagramRefresh", enabled: !IsBusy && tables.Any(t => !t.IsNew));
break;
}
return menu;
@@ -940,8 +966,8 @@ public async Task AddTablesAsync(IReadOnlyList chosen)
{
Say("Reading tables…", cancellable: true);
var known = tables.Select(t => t.Original.Oid).ToHashSet();
- var (designs, missing) = await new DiagramService(connection).ReadTablesAsync(chosen.Where(c => !known.Contains(c.Oid)).Select(c => new DiagramTablePlacement(c.Schema, c.Name, 0, 0) { Oid = c.Oid }).ToArray(), cancellation.Token);
- var added = designs.Where(d => !known.Contains(d.Oid)).Select(d => new DiagramTableModel(d, 0, 0)).ToList();
+ var (designs, missing, indexes) = await new DiagramService(connection).ReadTablesAndIndexesAsync(chosen.Where(c => !known.Contains(c.Oid)).Select(c => new DiagramTablePlacement(c.Schema, c.Name, 0, 0) { Oid = c.Oid }).ToArray(), [], cancellation.Token);
+ var added = designs.Where(d => !known.Contains(d.Oid)).Select(d => new DiagramTableModel(d, 0, 0) { Catalog = indexes.GetValueOrDefault(d.Oid) ?? DiagramTableIndexes.Empty }).ToList();
tables.AddRange(added);
Rebuild();
if (added.Count == 1)
@@ -1052,7 +1078,9 @@ private void ShowObjects(DiagramTableModel table, DesignerObjectKind kind, objec
Rebuild(); Touch();
}
public void ShowRelationships(DiagramTableModel table, DesignerForeignKeyRow? select = null) => ShowObjects(table, RelationshipsKind(table), select);
- public void ShowIndexes(DiagramTableModel table) => ShowObjects(table, DesignerObjectKinds.Indexes(table.Indexes, () => table.UniqueName($"ix_{table.Name}"), () => table.Columns.FirstOrDefault()?.Name, Touch, () => { }));
+ /// Indexes/Keys of the table; select names the index to show first (an index row of the Indexes section), when the dialog still has it.
+ public void ShowIndexes(DiagramTableModel table, string? select = null) => ShowObjects(table, DesignerObjectKinds.Indexes(table.Indexes, () => table.UniqueName($"ix_{table.Name}"), () => table.Columns.FirstOrDefault()?.Name, Touch, () => { }),
+ select is null ? null : table.Indexes.FirstOrDefault(i => i.Name == select));
public void ShowChecks(DiagramTableModel table) => ShowObjects(table, DesignerObjectKinds.Checks(table.Checks, () => table.UniqueName($"ck_{table.Name}"), Touch));
/// Properties (Alt+Enter): Table Properties of the selected table, or the selected relationship in Foreign Key Relationships.
@@ -1273,9 +1301,12 @@ public async Task RefreshTablesAsync()
{
var routes = CurrentLayout().Relationships ?? [];
bool wasDirty = IsDirty;
- var (designs, _) = await new DiagramService(connection).ReadTablesAsync(clean.Select(t => new DiagramTablePlacement(t.Schema, t.Name, t.X, t.Y) { Oid = t.Original.Oid }).ToArray(), cancellation.Token);
+ // The indexes of every table are read again, also of tables with pending changes: they come from the catalog and are not edited here.
+ var (designs, _, indexes) = await new DiagramService(connection).ReadTablesAndIndexesAsync(clean.Select(t => new DiagramTablePlacement(t.Schema, t.Name, t.X, t.Y) { Oid = t.Original.Oid }).ToArray(),
+ tables.Except(clean).Where(t => !t.IsNew).Select(t => t.Original.Oid).ToArray(), cancellation.Token);
var byOid = designs.ToDictionary(d => d.Oid);
foreach (var table in clean) if (byOid.TryGetValue(table.Original.Oid, out var read)) table.Reset(read);
+ foreach (var table in tables) table.Catalog = indexes.GetValueOrDefault(table.Original.Oid) ?? DiagramTableIndexes.Empty;
// Reset makes new key rows: the lines placed by hand are found again by name.
ApplyRoutes(routes);
if (!wasDirty) layoutBaseline = LayoutFingerprint();
@@ -1327,13 +1358,13 @@ public async Task SaveAsync()
var saved = await service.SaveAsync(info, chosen, CurrentLayout(), changes.Edits, cancellation.Token);
info = saved; name = saved.Name;
var savedLayout = CurrentLayout();
- var (designs, _) = await service.ReadTablesAsync(savedLayout.Tables, CancellationToken.None);
+ var (designs, _, indexes) = await service.ReadTablesAndIndexesAsync(savedLayout.Tables, [], CancellationToken.None);
var byOid = designs.ToDictionary(d => d.Oid);
var byName = designs.ToDictionary(d => d.Schema + "." + d.Name, StringComparer.Ordinal);
// A table created by this save had no OID when the layout was read: it is found by name.
var read = tables.ToDictionary(t => t, t => t.IsNew ? byName.GetValueOrDefault(t.Key) : byOid.GetValueOrDefault(t.Original.Oid));
tables.RemoveAll(t => read[t] is null);
- foreach (var table in tables) table.Reset(read[table]!);
+ foreach (var table in tables) { table.Reset(read[table]!); table.Catalog = indexes.GetValueOrDefault(table.Original.Oid) ?? DiagramTableIndexes.Empty; }
// Reset makes new key rows: the lines placed by hand are found again by name.
ApplyRoutes(savedLayout.Relationships);
dropped.Clear();
diff --git a/src/PGMS.Desktop/ExplorerMenus.cs b/src/PGMS.Desktop/ExplorerMenus.cs
index a73059e..e50fa93 100644
--- a/src/PGMS.Desktop/ExplorerMenus.cs
+++ b/src/PGMS.Desktop/ExplorerMenus.cs
@@ -205,6 +205,7 @@ void ServiceItem(string header, LocalServiceAction action, string name, bool all
{ var item = new MenuItem { Header = header, Name = name }; item.Click += click; tasks.Items.Add(item); }
menu.Items.Add(tasks);
Add("Find Missing _Foreign Keys…", FindMissingForeignKeys, "ExplorerMissingForeignKeys");
+ Add("Find _Unindexed Foreign Keys…", FindUnindexedForeignKeys, "ExplorerUnindexedForeignKeys");
Footer(true, ObjectProperties, "ExplorerObjectProperties", ServerReportService.DatabaseReports);
break;
case CatalogKind.Schemas:
diff --git a/src/PGMS.Desktop/ForeignKeyIndexScanWindow.xaml b/src/PGMS.Desktop/ForeignKeyIndexScanWindow.xaml
new file mode 100644
index 0000000..2964554
--- /dev/null
+++ b/src/PGMS.Desktop/ForeignKeyIndexScanWindow.xaml
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/PGMS.Desktop/ForeignKeyIndexScanWindow.xaml.cs b/src/PGMS.Desktop/ForeignKeyIndexScanWindow.xaml.cs
new file mode 100644
index 0000000..4166aa7
--- /dev/null
+++ b/src/PGMS.Desktop/ForeignKeyIndexScanWindow.xaml.cs
@@ -0,0 +1,164 @@
+using System.ComponentModel;
+using System.Globalization;
+using System.IO;
+using System.Windows;
+using Npgsql;
+using PGMS.PostgreSql;
+
+namespace PGMS.Desktop;
+
+/// One line of the scan: a foreign key that no index covers.
+public sealed class UnindexedForeignKeyRow(UnindexedForeignKey key) : INotifyPropertyChanged
+{
+ public UnindexedForeignKey Key { get; } = key;
+ public string Source => $"{Key.Key.Schema}.{Key.Key.Table} ({string.Join(", ", Key.Key.Columns)})";
+ public string Target => $"{Key.Key.TargetSchema}.{Key.Key.TargetTable}";
+ public string Size => FormatSize(Key.Key.TableBytes);
+ public string EstimatedRows => Key.Key.EstimatedRows.ToString("N0", CultureInfo.CurrentCulture);
+ public string ParentDeletes => Key.Key.ParentDeletes?.ToString("N0", CultureInfo.CurrentCulture) ?? "";
+ public string ParentUpdates => Key.Key.ParentUpdates?.ToString("N0", CultureInfo.CurrentCulture) ?? "";
+ public string NearIndexes => string.Join("; ", Key.NearIndexes);
+ private bool selected;
+ public bool Selected { get => selected; set { selected = value; Changed(nameof(Selected)); SelectionChanged?.Invoke(); } }
+ private string result = key.Key.Partitioned ? "No index. Partitioned: created without CONCURRENTLY" : "No index";
+ public string Result { get => result; internal set { result = value; Changed(nameof(Result)); } }
+ internal Action? SelectionChanged { get; set; }
+ internal bool IsCreated { get; private set; }
+ internal void MarkCreated() { IsCreated = true; Result = "Created"; Selected = false; }
+
+ internal static string FormatSize(long bytes)
+ {
+ string[] units = ["bytes", "kB", "MB", "GB", "TB"];
+ double value = bytes; int unit = 0;
+ while (value >= 1024 && unit < units.Length - 1) { value /= 1024; unit++; }
+ return value.ToString(unit == 0 ? "N0" : "N1", CultureInfo.CurrentCulture) + " " + units[unit];
+ }
+
+ public event PropertyChangedEventHandler? PropertyChanged;
+ private void Changed(string name) => PropertyChanged?.Invoke(this, new(name));
+}
+
+/// PGMS-039: foreign keys without an index, read from the catalog; the selected ones become a script reviewed before anything runs.
+public partial class ForeignKeyIndexScanWindow : Window
+{
+ private readonly string connection;
+ private readonly List rows = [];
+ private CancellationTokenSource? scan;
+ private bool applying;
+ /// Opens the script in a query tab.
+ internal Func? OpenInQuery { get; set; }
+ /// Replaces the review dialog in tests; returns true to run the script.
+ internal Func? ReviewScript { get; set; }
+ /// Set after indexes were created, so the host can refresh.
+ public bool Created { get; private set; }
+ internal IReadOnlyList Rows => rows;
+ internal bool CreateConcurrently { get => Concurrently.IsChecked == true; set { Concurrently.IsChecked = value; RefreshScript(); } }
+
+ public ForeignKeyIndexScanWindow(string connection)
+ {
+ InitializeComponent();
+ this.connection = connection;
+ }
+
+ private async void Scan(object sender, RoutedEventArgs e) => await ScanAsync();
+ private void OnClosing(object? sender, CancelEventArgs e) => scan?.Cancel();
+ private void ConcurrentlyChanged(object sender, RoutedEventArgs e) => RefreshScript();
+
+ internal async Task ScanAsync()
+ {
+ if (scan is not null || applying) return;
+ scan = new CancellationTokenSource();
+ var token = scan.Token;
+ ScanButton.IsEnabled = ApplyButton.IsEnabled = OpenScriptButton.IsEnabled = SelectAllButton.IsEnabled = false;
+ rows.Clear(); Keys.ItemsSource = null; Script.Clear();
+ ScanStatus.Text = "Reading the catalog…";
+ try
+ {
+ var (keys, names) = await ForeignKeyIndexScan.ReadAsync(connection, token);
+ rows.AddRange(ForeignKeyIndexScan.FindUnindexed(keys, names).Select(k => new UnindexedForeignKeyRow(k) { SelectionChanged = RefreshScript }));
+ Keys.ItemsSource = rows.ToList();
+ ScanStatus.Text = keys.Count == 0 ? "This database has no foreign keys."
+ : rows.Count == 0 ? $"Every one of the {keys.Count:N0} foreign key(s) has an index."
+ : $"{rows.Count:N0} of {keys.Count:N0} foreign key(s) have no index. Choose by table size and by how often the parent changes.";
+ }
+ catch (OperationCanceledException) { ScanStatus.Text = "Scan canceled."; }
+ catch (Exception ex) when (ex is NpgsqlException or IOException or InvalidOperationException or TimeoutException) { ScanStatus.Text = "Scan failed: " + (ex is PostgresException pg ? $"{pg.SqlState}: {pg.MessageText}" : ex.Message); }
+ finally
+ {
+ scan.Dispose(); scan = null;
+ ScanButton.IsEnabled = true;
+ SelectAllButton.IsEnabled = rows.Count > 0;
+ RefreshScript();
+ }
+ }
+
+ private void SelectAll(object sender, RoutedEventArgs e)
+ {
+ foreach (var row in rows.Where(r => !r.IsCreated)) row.Selected = true;
+ }
+
+ private List Chosen() => rows.Where(r => r.Selected).ToList();
+
+ private void RefreshScript()
+ {
+ var chosen = Chosen();
+ Script.Text = string.Join(Environment.NewLine, ForeignKeyIndexScan.Statements(chosen.Select(r => r.Key), CreateConcurrently));
+ ApplyButton.IsEnabled = OpenScriptButton.IsEnabled = scan is null && !applying && chosen.Count > 0;
+ }
+
+ private async void OpenScript(object sender, RoutedEventArgs e) { if (OpenInQuery is { } open && Script.Text.Length > 0) await open(Script.Text); }
+
+ private async void Apply(object sender, RoutedEventArgs e) => await ApplyAsync();
+
+ /// Always goes through the script review. Concurrently: one index at a time, stopping at the first failure. Otherwise: one transaction.
+ internal async Task ApplyAsync()
+ {
+ var chosen = Chosen();
+ if (chosen.Count == 0 || scan is not null || applying) return;
+ bool concurrently = CreateConcurrently;
+ var keys = chosen.Select(r => r.Key).ToList();
+ var statements = ForeignKeyIndexScan.Statements(keys, concurrently);
+ var warnings = new List
+ {
+ concurrently
+ ? "Indexes are created one at a time, outside a transaction, without blocking writes. If one fails, the ones before it stay and the failed one may be left invalid."
+ : "All indexes are created in one transaction. Writes on each table are blocked while its index is built."
+ };
+ if (concurrently)
+ warnings.AddRange(chosen.Where(r => r.Key.Key.Partitioned).Select(r => $"{r.Key.Key.Schema}.{r.Key.Key.Table} is partitioned: its index is created without CONCURRENTLY, and writes on its partitions are blocked meanwhile.").Distinct());
+ var change = new TableDesignChange(statements, warnings);
+ string intro = concurrently ? "Review the script. Apply runs one statement at a time, outside a transaction." : "Review the script. Apply runs it in one transaction.";
+ if (!(ReviewScript?.Invoke(change) ?? new ChangeScriptDialog(change, canApply: true, intro) { Owner = this }.ShowDialog() == true)) return;
+ applying = true;
+ ScanButton.IsEnabled = SelectAllButton.IsEnabled = Concurrently.IsEnabled = ApplyButton.IsEnabled = OpenScriptButton.IsEnabled = false;
+ ScanStatus.Text = "Creating the indexes…";
+ try
+ {
+ var result = await ForeignKeyIndexScan.ApplyAsync(connection, keys, concurrently);
+ // The statements run in script order, so the first ones are the ones that were created.
+ var done = statements.Take(result.Created).ToHashSet(StringComparer.Ordinal);
+ var failed = concurrently && result.Error is not null && result.Created < statements.Count ? statements[result.Created] : null;
+ foreach (var row in chosen)
+ {
+ string statement = ForeignKeyIndexScan.Statement(row.Key, concurrently);
+ if (done.Contains(statement)) row.MarkCreated();
+ else if (statement == failed) row.Result = "Failed: " + result.Error;
+ }
+ if (result.Created > 0) Created = true;
+ ScanStatus.Text = result.Error is null ? $"{result.Created:N0} index(es) created."
+ : !concurrently ? "Nothing was created: " + result.Error
+ : $"{result.Created:N0} index(es) created, then stopped: {result.Error}" + (result.InvalidIndex is { } invalid ? $" An invalid index was left; remove it with {ForeignKeyIndexScan.DropInvalid(result.InvalidIndexSchema!, invalid)}" : "");
+ }
+ catch (Exception ex) when (ex is NpgsqlException or IOException or InvalidOperationException or TimeoutException)
+ {
+ ScanStatus.Text = "Apply stopped: " + ex.Message + " Scan again to see what was created.";
+ }
+ finally
+ {
+ applying = false;
+ ScanButton.IsEnabled = Concurrently.IsEnabled = true;
+ SelectAllButton.IsEnabled = rows.Count > 0;
+ RefreshScript();
+ }
+ }
+}
diff --git a/src/PGMS.Desktop/MainWindow.Diagrams.cs b/src/PGMS.Desktop/MainWindow.Diagrams.cs
index 183f998..cb014db 100644
--- a/src/PGMS.Desktop/MainWindow.Diagrams.cs
+++ b/src/PGMS.Desktop/MainWindow.Diagrams.cs
@@ -94,6 +94,7 @@ private DatabaseDiagramView OpenDiagramDocument(string connection, int? id, stri
view.TitleChanged += () => { document.Title = view.Title; RefreshCommands(); };
view.Saved += changedTables => _ = AfterDiagramSavedAsync(view, changedTables);
view.DesignRequested += table => OpenTableDesigner(connection, table.Schema, table.Name);
+ view.UnindexedForeignKeysRequested += () => _ = ShowUnindexedForeignKeysAsync(connection);
document.Closing += (_, e) => { if (acceptedDiagramCloses.Remove(view)) return; e.Cancel = true; _ = CloseDiagramAsync(view); };
document.Closed += (_, _) => { diagrams.Remove(view); view.Shutdown(); RefreshCommands(); };
AddDocument(document);
diff --git a/src/PGMS.Desktop/MainWindow.QueryMenu.cs b/src/PGMS.Desktop/MainWindow.QueryMenu.cs
index 70a64d1..4d60d5d 100644
--- a/src/PGMS.Desktop/MainWindow.QueryMenu.cs
+++ b/src/PGMS.Desktop/MainWindow.QueryMenu.cs
@@ -131,6 +131,34 @@ private async void FindMissingForeignKeys(object sender, RoutedEventArgs e)
if (window.Created) await RefreshSelectedExplorerAsync();
}
+ private async void FindUnindexedForeignKeys(object sender, RoutedEventArgs e)
+ {
+ if (SelectedConnection is not { } connection) { Status.Text = "Connect before scanning for foreign keys."; return; }
+ if (await ShowUnindexedForeignKeysAsync(connection)) await RefreshSelectedExplorerAsync();
+ }
+
+ internal Action? UnindexedForeignKeysShown { get; set; }
+
+ /// Find Unindexed Foreign Keys… on a database, from Object Explorer or from the warning of a diagram (PGMS-044). True when indexes were created;
+ /// the diagrams open on that database then read their indexes again.
+ internal async Task ShowUnindexedForeignKeysAsync(string connection)
+ {
+ var window = new ForeignKeyIndexScanWindow(connection)
+ {
+ Owner = this,
+ OpenInQuery = async script =>
+ {
+ try { await OpenQueryTextAsync(connection, script); }
+ catch (Exception ex) when (ex is Npgsql.NpgsqlException or IOException or InvalidOperationException) { ShowError(ex); }
+ }
+ };
+ UnindexedForeignKeysShown?.Invoke(window);
+ window.ShowDialog();
+ if (!window.Created) return false;
+ foreach (var diagram in diagrams.Keys.Where(d => d.OnDatabase(connection)).ToList()) await diagram.RefreshTablesAsync();
+ return true;
+ }
+
private readonly List planDocuments = [];
internal IReadOnlyList PlanDocuments => planDocuments;
private int planDocumentSequence;
diff --git a/src/PGMS.PostgreSql/DatabaseDiagrams.cs b/src/PGMS.PostgreSql/DatabaseDiagrams.cs
index 1883e3d..a7c04c8 100644
--- a/src/PGMS.PostgreSql/DatabaseDiagrams.cs
+++ b/src/PGMS.PostgreSql/DatabaseDiagrams.cs
@@ -13,6 +13,13 @@ public sealed record DiagramTablePlacement(string Schema, string Name, double X,
public string? View { get; init; }
/// The fields of the Custom view, when they differ from the default.
public IReadOnlyList? CustomColumns { get; init; }
+ /// The Indexes section of the table is open (PGMS-044); it starts closed so diagrams laid out before keep their spacing.
+ public bool IndexesExpanded { get; init; }
+ /// The indexes of the table as the catalog had them when the diagram was saved, for tools and AI agents that read the diagram (PGMS-044).
+ /// Written by every save from the catalog, inside the save transaction; never read back as truth: opening a diagram reads the catalog again.
+ public IReadOnlyList? Indexes { get; init; }
+ /// Foreign keys of the table that no index serves, when the diagram was saved.
+ public IReadOnlyList? UnindexedForeignKeys { get; init; }
}
/// A text annotation placed on a diagram.
@@ -25,7 +32,14 @@ public sealed record DiagramRelationshipRoute(string Schema, string Table, strin
/// What a diagram stores: which tables it shows and where, and the lines placed by hand. Never table data (decision of 07/11/2026, docs/MARCO-M1043.md).
public sealed record DiagramLayout
{
- public int Format { get; init; } = 1;
+ /// 1: layout only (M1043). 2: the indexes of each table, Show Indexes and IndexesExpanded (PGMS-044). A diagram of format 1 opens as it was
+ /// and is written as format 2 by the next save; older versions of PGMS ignore what they do not know.
+ public const int CurrentFormat = 2;
+ public int Format { get; init; } = CurrentFormat;
+ /// Show Indexes of the diagram menu (PGMS-044), on by default and kept by diagram.
+ public bool ShowIndexes { get; init; } = true;
+ /// When the save read the indexes written in Tables; null when they were not read (a layout not saved yet, or format 1).
+ public DateTimeOffset? CatalogRead { get; init; }
public IReadOnlyList Tables { get; init; } = [];
/// Only the lines moved by hand; the others are routed automatically. Older versions of PGMS ignore this list.
public IReadOnlyList Relationships { get; init; } = [];
@@ -257,6 +271,16 @@ private async Task> TablesAsync(string sql, uint[
/// Reads the tables of a diagram in one snapshot. A table that no longer exists is left out and named in Missing.
public async Task<(IReadOnlyList Tables, IReadOnlyList Missing)> ReadTablesAsync(IReadOnlyList placements, CancellationToken token = default)
+ {
+ var read = await ReadTablesAndIndexesAsync(placements, [], token).ConfigureAwait(false);
+ return (read.Tables, read.Missing);
+ }
+
+ public sealed record TablesRead(IReadOnlyList Tables, IReadOnlyList Missing, IReadOnlyDictionary Indexes);
+
+ /// The tables as reads them and, in the same snapshot, the indexes of those tables and of alsoIndexesOf
+ /// (tables of the diagram with pending changes, whose design is not read again but whose indexes are).
+ public async Task ReadTablesAndIndexesAsync(IReadOnlyList placements, IReadOnlyCollection alsoIndexesOf, CancellationToken token = default)
{
await using var connection = await OpenAsync(token).ConfigureAwait(false);
await using var transaction = await connection.BeginTransactionAsync(System.Data.IsolationLevel.RepeatableRead, token).ConfigureAwait(false);
@@ -275,8 +299,37 @@ private async Task> TablesAsync(string sql, uint[
if (!await ExistsAsync(connection, transaction, schema, name, token).ConfigureAwait(false)) { missing.Add(placement.Schema + "." + placement.Name); continue; }
tables.Add(await TableDesignService.ReadAsync(connection, transaction, schema, name, token).ConfigureAwait(false));
}
+ var indexes = await DiagramIndexReader.ReadAsync(connection, transaction, tables.Select(t => t.Oid).Concat(alsoIndexesOf).ToArray(), token).ConfigureAwait(false);
await transaction.CommitAsync(token).ConfigureAwait(false);
- return (tables, missing);
+ return new(tables, missing, indexes);
+ }
+
+ /// The layout with the indexes each table has now, read inside the save transaction after the schema changes ran, so the saved diagram
+ /// tells the truth about the tables it was saved with. Tables are found by name: a table the save just created has no OID in the layout yet.
+ private static async Task WithCatalogAsync(NpgsqlConnection connection, NpgsqlTransaction transaction, DiagramLayout layout, CancellationToken token)
+ {
+ var found = new Dictionary();
+ await using (var command = new NpgsqlCommand("""
+ SELECT s.ord::int, c.oid FROM unnest(@schemas::text[], @names::text[]) WITH ORDINALITY s(schema_name, table_name, ord)
+ JOIN pg_catalog.pg_namespace n ON n.nspname = s.schema_name JOIN pg_catalog.pg_class c ON c.relnamespace = n.oid AND c.relname = s.table_name AND c.relkind IN ('r','p')
+ """, connection, transaction) { CommandTimeout = 15 })
+ {
+ command.Parameters.AddWithValue("schemas", layout.Tables.Select(t => t.Schema).ToArray());
+ command.Parameters.AddWithValue("names", layout.Tables.Select(t => t.Name).ToArray());
+ await using var reader = await command.ExecuteReaderAsync(token).ConfigureAwait(false);
+ while (await reader.ReadAsync(token).ConfigureAwait(false)) found[reader.GetInt32(0) - 1] = reader.GetFieldValue(1);
+ }
+ var indexes = await DiagramIndexReader.ReadAsync(connection, transaction, found.Values.ToArray(), token).ConfigureAwait(false);
+ DateTimeOffset read;
+ await using (var now = new NpgsqlCommand("SELECT statement_timestamp()", connection, transaction) { CommandTimeout = 15 })
+ read = new DateTimeOffset(DateTime.SpecifyKind((DateTime)(await now.ExecuteScalarAsync(token).ConfigureAwait(false))!, DateTimeKind.Utc));
+ return layout with
+ {
+ Format = DiagramLayout.CurrentFormat, CatalogRead = read,
+ Tables = layout.Tables.Select((t, i) => found.TryGetValue(i, out var oid) && indexes.TryGetValue(oid, out var table)
+ ? t with { Oid = oid, Indexes = table.Indexes, UnindexedForeignKeys = table.UnindexedForeignKeys }
+ : t with { Indexes = null, UnindexedForeignKeys = null }).ToArray()
+ };
}
private static async Task ExistsAsync(NpgsqlConnection connection, NpgsqlTransaction transaction, string schema, string name, CancellationToken token)
{
@@ -302,6 +355,7 @@ public async Task SaveAsync(DiagramInfo? existing, string name, Dia
await edit.VerifyAppliedAsync(connection, transaction, token).ConfigureAwait(false);
}
if (notice is not null) throw new InvalidOperationException("The save was rolled back after PostgreSQL notice: " + notice);
+ layout = await WithCatalogAsync(connection, transaction, layout, token).ConfigureAwait(false);
string sql = existing is null
? $"INSERT INTO pgms.diagrams (name, definition) VALUES (@name, @definition::jsonb) RETURNING {InfoColumns}"
: $"UPDATE pgms.diagrams SET name=@name, definition=@definition::jsonb, version=version+1, modified=now() WHERE diagram_id=@id AND version=@version RETURNING {InfoColumns}";
diff --git a/src/PGMS.PostgreSql/DiagramIndexes.cs b/src/PGMS.PostgreSql/DiagramIndexes.cs
new file mode 100644
index 0000000..a1eeb7a
--- /dev/null
+++ b/src/PGMS.PostgreSql/DiagramIndexes.cs
@@ -0,0 +1,128 @@
+using System.Text.Json.Serialization;
+using Npgsql;
+using NpgsqlTypes;
+
+namespace PGMS.PostgreSql;
+
+/// One key of an index as the catalog has it. Column is the column name, null when the key is an expression; Expression is the text PostgreSQL
+/// shows (quoted when needed); OperatorClass is set only when it is not the default for the type and method (gin_trgm_ops, jsonb_path_ops, text_pattern_ops).
+public sealed record DiagramIndexKey(string Expression)
+{
+ public string? Column { get; init; }
+ public string? OperatorClass { get; init; }
+ public bool Descending { get; init; }
+
+ [JsonIgnore] public string Text => Expression + (OperatorClass is { } opclass ? " " + opclass : "") + (Descending ? " DESC" : "");
+}
+
+/// An index of a table in a diagram (PGMS-044). Read from the catalog, never edited here: creating or changing an index stays in the existing dialogs.
+public sealed record DiagramIndex(string Name, string Method, IReadOnlyList Keys)
+{
+ public bool Primary { get; init; }
+ public bool Unique { get; init; }
+ /// The index of an EXCLUDE constraint.
+ public bool Exclusion { get; init; }
+ /// False for an index a failed CREATE INDEX CONCURRENTLY left behind: the planner does not use it.
+ public bool Valid { get; init; } = true;
+ public IReadOnlyList Include { get; init; } = [];
+ /// The WHERE of a partial index, as PostgreSQL shows it.
+ public string? Predicate { get; init; }
+ public string Definition { get; init; } = "";
+
+ /// The method in the short form of the diagram: nothing for b-tree, the usual name of the others.
+ [JsonIgnore] public string MethodLabel => Method switch { "btree" => "", "hash" => "Hash", "gin" => "GIN", "gist" => "GiST", "spgist" => "SP-GiST", "brin" => "BRIN", _ => Method };
+ [JsonIgnore] public string Kind => Primary ? "Primary key" : Exclusion ? "Exclusion constraint" : Unique ? "Unique" : "Index";
+ /// The keys in parentheses, then INCLUDE; the predicate stays in the tooltip.
+ [JsonIgnore] public string KeysText => "(" + string.Join(", ", Keys.Select(k => k.Text)) + ")" + (Include.Count > 0 ? " INCLUDE (" + string.Join(", ", Include) + ")" : "");
+ [JsonIgnore] public IReadOnlyList Flags => [.. new[] { Primary ? "PRIMARY KEY" : Exclusion ? "EXCLUDE" : Unique ? "UNIQUE" : null, Predicate is not null ? "partial" : null, Valid ? null : "invalid" }.OfType()];
+
+ internal ScanIndex ForScan() => new(Name, Keys.Select(k => k.Column).ToList(), Valid, Predicate is not null, Method);
+}
+
+/// A foreign key of the table that no index serves, by the rule of Find Unindexed Foreign Keys (PGMS-039); NearIndexes says which indexes almost do.
+public sealed record DiagramUnindexedKey(string Name, IReadOnlyList Columns)
+{
+ public IReadOnlyList NearIndexes { get; init; } = [];
+}
+
+/// What the catalog says about the indexes of one table.
+public sealed record DiagramTableIndexes(IReadOnlyList Indexes, IReadOnlyList UnindexedForeignKeys)
+{
+ public static readonly DiagramTableIndexes Empty = new([], []);
+}
+
+/// Reads the indexes of the tables of a diagram from the catalog (PGMS-044). Read only.
+public static class DiagramIndexReader
+{
+ private const string IndexSql = """
+ SELECT i.indrelid, c.relname::text, am.amname::text, i.indisprimary, i.indisunique, COALESCE(con.contype = 'x', false), i.indisvalid,
+ pg_catalog.pg_get_expr(i.indpred, i.indrelid, true), pg_catalog.pg_get_indexdef(i.indexrelid), i.indnkeyatts::int,
+ ARRAY(SELECT pg_catalog.pg_get_indexdef(i.indexrelid, k, true) FROM pg_catalog.generate_series(1, i.indnatts::int) k ORDER BY k),
+ ARRAY(SELECT a.attname::text FROM pg_catalog.generate_series(1, i.indnatts::int) k LEFT JOIN pg_catalog.pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = i.indkey[k - 1] AND i.indkey[k - 1] > 0 ORDER BY k),
+ ARRAY(SELECT CASE WHEN oc.opcdefault THEN NULL WHEN pg_catalog.pg_opclass_is_visible(oc.oid) THEN pg_catalog.quote_ident(oc.opcname) ELSE pg_catalog.quote_ident(ocn.nspname) || '.' || pg_catalog.quote_ident(oc.opcname) END
+ FROM pg_catalog.generate_series(1, i.indnkeyatts::int) k LEFT JOIN pg_catalog.pg_opclass oc ON oc.oid = i.indclass[k - 1] LEFT JOIN pg_catalog.pg_namespace ocn ON ocn.oid = oc.opcnamespace ORDER BY k),
+ ARRAY(SELECT (i.indoption[k - 1] & 1) = 1 FROM pg_catalog.generate_series(1, i.indnkeyatts::int) k ORDER BY k)
+ FROM pg_catalog.pg_index i JOIN pg_catalog.pg_class c ON c.oid = i.indexrelid JOIN pg_catalog.pg_am am ON am.oid = c.relam
+ LEFT JOIN pg_catalog.pg_constraint con ON con.conindid = i.indexrelid AND con.conrelid = i.indrelid AND con.contype IN ('p','u','x')
+ WHERE i.indrelid = ANY(@oids) AND i.indislive
+ ORDER BY i.indrelid, i.indisprimary DESC, i.indisunique DESC, c.relname
+ """;
+
+ private const string ForeignKeySql = """
+ SELECT con.conrelid, con.conname::text,
+ ARRAY(SELECT a.attname::text FROM unnest(con.conkey) WITH ORDINALITY k(attnum, ord) JOIN pg_catalog.pg_attribute a ON a.attrelid = con.conrelid AND a.attnum = k.attnum ORDER BY k.ord)
+ FROM pg_catalog.pg_constraint con
+ WHERE con.contype = 'f' AND con.conparentid = 0 AND con.conrelid = ANY(@oids)
+ ORDER BY con.conrelid, con.conname
+ """;
+
+ /// The indexes and the foreign keys without an index of each table, in a stable order: primary key, unique, the others, each by name.
+ public static async Task> ReadAsync(NpgsqlConnection connection, NpgsqlTransaction? transaction, IReadOnlyCollection oids, CancellationToken token = default)
+ {
+ var result = new Dictionary();
+ var wanted = oids.Where(o => o != 0).Distinct().ToArray();
+ if (wanted.Length == 0) return result;
+ var indexes = wanted.ToDictionary(o => o, _ => new List());
+ await using (var command = new NpgsqlCommand(IndexSql, connection, transaction) { CommandTimeout = 30 })
+ {
+ command.Parameters.AddWithValue("oids", NpgsqlDbType.Array | NpgsqlDbType.Oid, wanted);
+ await using var reader = await command.ExecuteReaderAsync(token).ConfigureAwait(false);
+ while (await reader.ReadAsync(token).ConfigureAwait(false))
+ {
+ int keyCount = reader.GetInt32(9);
+ var texts = reader.GetFieldValue(10);
+ var columns = reader.GetFieldValue(11);
+ var classes = reader.GetFieldValue(12);
+ var descending = reader.GetFieldValue(13);
+ var keys = Enumerable.Range(0, Math.Min(keyCount, texts.Length)).Select(k => new DiagramIndexKey(texts[k])
+ {
+ Column = k < columns.Length ? columns[k] : null, OperatorClass = k < classes.Length ? classes[k] : null, Descending = k < descending.Length && descending[k]
+ }).ToArray();
+ indexes[reader.GetFieldValue(0)].Add(new(reader.GetString(1), reader.GetString(2), keys)
+ {
+ Primary = reader.GetBoolean(3), Unique = reader.GetBoolean(4), Exclusion = reader.GetBoolean(5), Valid = reader.GetBoolean(6),
+ Predicate = reader.IsDBNull(7) ? null : reader.GetString(7), Definition = reader.GetString(8),
+ Include = texts.Skip(keyCount).ToArray()
+ });
+ }
+ }
+ var keysByTable = wanted.ToDictionary(o => o, _ => new List<(string Name, string[] Columns)>());
+ await using (var command = new NpgsqlCommand(ForeignKeySql, connection, transaction) { CommandTimeout = 30 })
+ {
+ command.Parameters.AddWithValue("oids", NpgsqlDbType.Array | NpgsqlDbType.Oid, wanted);
+ await using var reader = await command.ExecuteReaderAsync(token).ConfigureAwait(false);
+ while (await reader.ReadAsync(token).ConfigureAwait(false))
+ keysByTable[reader.GetFieldValue(0)].Add((reader.GetString(1), reader.GetFieldValue(2)));
+ }
+ foreach (uint oid in wanted) result[oid] = new(indexes[oid], Unindexed(keysByTable[oid], indexes[oid]));
+ return result;
+ }
+
+ /// The same rule as Find Unindexed Foreign Keys: a valid, non-partial b-tree or hash index whose first keys are the columns of the key, in any order.
+ public static IReadOnlyList Unindexed(IEnumerable<(string Name, string[] Columns)> foreignKeys, IReadOnlyList indexes)
+ {
+ var scan = indexes.Select(i => i.ForScan()).ToArray();
+ return foreignKeys.Where(k => k.Columns.Length > 0 && !scan.Any(i => ForeignKeyIndexScan.Covers(k.Columns, i)))
+ .Select(k => new DiagramUnindexedKey(k.Name, k.Columns) { NearIndexes = scan.Select(i => ForeignKeyIndexScan.NearReason(k.Columns, i)).OfType().ToArray() }).ToArray();
+ }
+}
diff --git a/src/PGMS.PostgreSql/ForeignKeyIndexScan.cs b/src/PGMS.PostgreSql/ForeignKeyIndexScan.cs
new file mode 100644
index 0000000..ee60528
--- /dev/null
+++ b/src/PGMS.PostgreSql/ForeignKeyIndexScan.cs
@@ -0,0 +1,197 @@
+using System.Text;
+using Npgsql;
+
+namespace PGMS.PostgreSql;
+
+/// An index as the scan sees it. KeyColumns holds the key columns in order, null where the key is an expression; INCLUDE columns are left out.
+public sealed record ScanIndex(string Name, IReadOnlyList KeyColumns, bool Valid, bool Partial, string Method);
+
+/// A foreign key and what is known about its tables. Sizes and counters come from the catalog and the statistics, never from the rows.
+public sealed record ScanForeignKey(string Schema, string Table, string Name, IReadOnlyList Columns, string TargetSchema, string TargetTable, bool Partitioned, long TableBytes, double EstimatedRows, long? ParentDeletes, long? ParentUpdates, IReadOnlyList Indexes);
+
+/// A foreign key that no index covers. NearIndexes says which indexes almost do and why they do not. Keys on the same columns of the same table share IndexName.
+public sealed record UnindexedForeignKey(ScanForeignKey Key, string IndexName, IReadOnlyList NearIndexes);
+
+/// What Apply did. InvalidIndex names an index a failed CREATE INDEX CONCURRENTLY left behind.
+public sealed record IndexApplyResult(int Created, string? Error, string? InvalidIndex, string? InvalidIndexSchema = null);
+
+/// Finds foreign keys whose columns have no usable index (PGMS-039). PostgreSQL indexes the referenced side only, so deleting or updating a parent row scans the child table. Reads the catalog only; changes nothing except in .
+public static class ForeignKeyIndexScan
+{
+ /// An index serves the key when it is valid, covers every row, and its first key columns are exactly the columns of the key, in any order.
+ public static bool Covers(IReadOnlyList keyColumns, ScanIndex index) =>
+ index.Valid && !index.Partial && index.Method is "btree" or "hash" && LeadsWith(keyColumns, index);
+
+ private static bool LeadsWith(IReadOnlyList keyColumns, ScanIndex index) =>
+ index.KeyColumns.Count >= keyColumns.Count && index.KeyColumns.Take(keyColumns.Count).All(c => c is not null && keyColumns.Contains(c)) && index.KeyColumns.Take(keyColumns.Count).Distinct().Count() == keyColumns.Count;
+
+ /// Why an index that touches the key does not serve it; null when the index has nothing to do with the key.
+ internal static string? NearReason(IReadOnlyList keyColumns, ScanIndex index)
+ {
+ if (LeadsWith(keyColumns, index))
+ return !index.Valid ? $"{index.Name} is invalid"
+ : index.Partial ? $"{index.Name} is partial"
+ : $"{index.Name} is {index.Method}, not b-tree";
+ if (keyColumns.All(c => index.KeyColumns.Contains(c))) return $"{index.Name} has the columns, but not first";
+ if (keyColumns.Count > 1 && index.KeyColumns.Count > 0 && index.KeyColumns[0] is { } first && keyColumns.Contains(first)) return $"{index.Name} covers only part of the key";
+ return null;
+ }
+
+ /// The keys without an index, in table order. Keys on the same columns of one table get the same index name, so one index serves them.
+ public static IReadOnlyList FindUnindexed(IReadOnlyList keys, IReadOnlySet<(string Schema, string Name)> relationNames)
+ {
+ var taken = new HashSet<(string, string)>(relationNames);
+ var named = new Dictionary(StringComparer.Ordinal);
+ var found = new List();
+ foreach (var key in keys)
+ {
+ if (key.Columns.Count == 0 || key.Indexes.Any(i => Covers(key.Columns, i))) continue;
+ string same = key.Schema + "\0" + key.Table + "\0" + string.Join("\0", key.Columns);
+ if (!named.TryGetValue(same, out var name))
+ {
+ string wanted = $"ix_{key.Table}_{string.Join("_", key.Columns)}";
+ name = Fit(wanted, "");
+ for (int n = 1; !taken.Add((key.Schema, name)); n++) name = Fit(wanted, "_" + n.ToString(System.Globalization.CultureInfo.InvariantCulture));
+ named[same] = name;
+ }
+ found.Add(new(key, name, key.Indexes.Select(i => NearReason(key.Columns, i)).OfType().ToList()));
+ }
+ return found;
+ }
+
+ /// A name of at most 63 bytes; a long one keeps a hash of the full name so two long names do not collide.
+ internal static string Fit(string name, string suffix)
+ {
+ if (Encoding.UTF8.GetByteCount(name + suffix) <= 63) return name + suffix;
+ string hash = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(Encoding.UTF8.GetBytes(name)))[..8].ToLowerInvariant();
+ int room = 63 - 9 - Encoding.UTF8.GetByteCount(suffix);
+ var kept = new StringBuilder();
+ foreach (var rune in name.EnumerateRunes())
+ {
+ if (Encoding.UTF8.GetByteCount(kept.ToString() + rune) > room) break;
+ kept.Append(rune.ToString());
+ }
+ return kept + "_" + hash + suffix;
+ }
+
+ private const string UserTables = "c.relkind IN ('r','p') AND n.nspname NOT IN ('pg_catalog','information_schema') AND n.nspname NOT LIKE 'pg\\_%'";
+
+ /// Reads every foreign key of ordinary and partitioned tables outside the system schemas, with the indexes of its table. A key inherited by a partition is left out: the one on the parent stands for it.
+ public static async Task<(IReadOnlyList Keys, IReadOnlySet<(string Schema, string Name)> RelationNames)> ReadAsync(string connectionString, CancellationToken token = default)
+ {
+ await using var connection = new NpgsqlConnection(connectionString);
+ await connection.OpenAsync(token).ConfigureAwait(false);
+ await using var transaction = await connection.BeginTransactionAsync(System.Data.IsolationLevel.RepeatableRead, token).ConfigureAwait(false);
+ await using (var setup = new NpgsqlCommand("SET TRANSACTION READ ONLY", connection, transaction))
+ await setup.ExecuteNonQueryAsync(token).ConfigureAwait(false);
+
+ var attributes = new Dictionary<(uint, short), string>();
+ await using (var command = new NpgsqlCommand("SELECT a.attrelid, a.attnum, a.attname::text FROM pg_catalog.pg_attribute a WHERE a.attnum > 0 AND NOT a.attisdropped AND a.attrelid IN (SELECT conrelid FROM pg_catalog.pg_constraint WHERE contype = 'f')", connection, transaction) { CommandTimeout = 60 })
+ await using (var reader = await command.ExecuteReaderAsync(token).ConfigureAwait(false))
+ while (await reader.ReadAsync(token).ConfigureAwait(false))
+ attributes[(reader.GetFieldValue(0), reader.GetInt16(1))] = reader.GetString(2);
+
+ var indexes = new Dictionary>();
+ await using (var command = new NpgsqlCommand("SELECT i.indrelid, ic.relname::text, i.indkey::int2[], i.indnkeyatts, i.indisvalid, i.indpred IS NOT NULL, am.amname::text FROM pg_catalog.pg_index i JOIN pg_catalog.pg_class ic ON ic.oid = i.indexrelid JOIN pg_catalog.pg_am am ON am.oid = ic.relam WHERE i.indislive AND i.indrelid IN (SELECT conrelid FROM pg_catalog.pg_constraint WHERE contype = 'f') ORDER BY ic.relname", connection, transaction) { CommandTimeout = 60 })
+ await using (var reader = await command.ExecuteReaderAsync(token).ConfigureAwait(false))
+ while (await reader.ReadAsync(token).ConfigureAwait(false))
+ {
+ uint table = reader.GetFieldValue(0);
+ short[] key = reader.GetFieldValue(2);
+ var columns = key.Take(reader.GetInt16(3)).Select(n => n > 0 && attributes.TryGetValue((table, n), out var name) ? name : null).ToList();
+ if (!indexes.TryGetValue(table, out var list)) indexes[table] = list = [];
+ list.Add(new(reader.GetString(1), columns, reader.GetBoolean(4), reader.GetBoolean(5), reader.GetString(6)));
+ }
+
+ var names = new HashSet<(string, string)>();
+ await using (var command = new NpgsqlCommand("SELECT n.nspname::text, c.relname::text FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace WHERE n.nspname NOT IN ('pg_catalog','information_schema') AND n.nspname NOT LIKE 'pg\\_%'", connection, transaction) { CommandTimeout = 60 })
+ await using (var reader = await command.ExecuteReaderAsync(token).ConfigureAwait(false))
+ while (await reader.ReadAsync(token).ConfigureAwait(false))
+ names.Add((reader.GetString(0), reader.GetString(1)));
+
+ // A partitioned table holds no rows itself: its size and row estimate are those of its leaves.
+ const string Sql = $"""
+ SELECT con.conrelid, n.nspname::text, c.relname::text, con.conname::text, con.conkey, c.relkind = 'p', fn.nspname::text, fc.relname::text,
+ CASE WHEN c.relkind = 'p' THEN (SELECT COALESCE(sum(pg_catalog.pg_table_size(t.relid)), 0) FROM pg_catalog.pg_partition_tree(c.oid) t WHERE t.isleaf) ELSE pg_catalog.pg_table_size(c.oid) END::bigint,
+ CASE WHEN c.relkind = 'p' THEN (SELECT COALESCE(sum(GREATEST(l.reltuples, 0)), 0) FROM pg_catalog.pg_partition_tree(c.oid) t JOIN pg_catalog.pg_class l ON l.oid = t.relid WHERE t.isleaf) ELSE GREATEST(c.reltuples, 0) END::float8,
+ s.n_tup_del, s.n_tup_upd
+ FROM pg_catalog.pg_constraint con
+ JOIN pg_catalog.pg_class c ON c.oid = con.conrelid JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
+ JOIN pg_catalog.pg_class fc ON fc.oid = con.confrelid JOIN pg_catalog.pg_namespace fn ON fn.oid = fc.relnamespace
+ LEFT JOIN pg_catalog.pg_stat_all_tables s ON s.relid = con.confrelid
+ WHERE con.contype = 'f' AND con.conparentid = 0 AND {UserTables}
+ ORDER BY n.nspname, c.relname, con.conname
+ """;
+ var keys = new List();
+ await using (var command = new NpgsqlCommand(Sql, connection, transaction) { CommandTimeout = 120 })
+ await using (var reader = await command.ExecuteReaderAsync(token).ConfigureAwait(false))
+ while (await reader.ReadAsync(token).ConfigureAwait(false))
+ {
+ uint table = reader.GetFieldValue(0);
+ var columns = reader.GetFieldValue(4).Select(n => attributes.GetValueOrDefault((table, n))).OfType().ToList();
+ keys.Add(new(reader.GetString(1), reader.GetString(2), reader.GetString(3), columns, reader.GetString(6), reader.GetString(7), reader.GetBoolean(5), reader.GetInt64(8), reader.GetDouble(9),
+ reader.IsDBNull(10) ? null : reader.GetInt64(10), reader.IsDBNull(11) ? null : reader.GetInt64(11), indexes.TryGetValue(table, out var list) ? list : []));
+ }
+ return (keys, names);
+ }
+
+ private static string Id(string name) => "\"" + name.Replace("\"", "\"\"", StringComparison.Ordinal) + "\"";
+
+ /// True when the statement for this key carries CONCURRENTLY: a partitioned table does not accept it.
+ public static bool IsConcurrent(UnindexedForeignKey key, bool concurrently) => concurrently && !key.Key.Partitioned;
+
+ /// The statement for one key. CONCURRENTLY does not block writes but cannot run in a transaction.
+ public static string Statement(UnindexedForeignKey key, bool concurrently) =>
+ $"CREATE INDEX {(IsConcurrent(key, concurrently) ? "CONCURRENTLY " : "")}{Id(key.IndexName)} ON {Id(key.Key.Schema)}.{Id(key.Key.Table)} ({string.Join(", ", key.Key.Columns.Select(Id))});";
+
+ /// One statement per index: keys that share an index give a single statement.
+ public static IReadOnlyList Statements(IEnumerable keys, bool concurrently) =>
+ keys.Select(k => Statement(k, concurrently)).Distinct(StringComparer.Ordinal).ToList();
+
+ /// In one transaction, every index is created or none. One at a time (needed by CONCURRENTLY), it stops at the first failure: the indexes before it stay, and the failed one may be left invalid.
+ public static async Task ApplyAsync(string connectionString, IReadOnlyList keys, bool concurrently, CancellationToken token = default)
+ {
+ var indexes = keys.GroupBy(k => Statement(k, concurrently), StringComparer.Ordinal).Select(g => g.First()).ToList();
+ await using var connection = new NpgsqlConnection(connectionString);
+ await connection.OpenAsync(token).ConfigureAwait(false);
+ if (!concurrently)
+ {
+ await using var transaction = await connection.BeginTransactionAsync(token).ConfigureAwait(false);
+ try
+ {
+ foreach (var index in indexes)
+ {
+ await using var command = new NpgsqlCommand(Statement(index, false), connection, transaction) { CommandTimeout = 0 };
+ await command.ExecuteNonQueryAsync(token).ConfigureAwait(false);
+ }
+ await transaction.CommitAsync(token).ConfigureAwait(false);
+ return new(indexes.Count, null, null);
+ }
+ catch (PostgresException ex) { return new(0, $"{ex.SqlState}: {ex.MessageText}", null); }
+ }
+ int created = 0;
+ foreach (var index in indexes)
+ {
+ try
+ {
+ await using var command = new NpgsqlCommand(Statement(index, true), connection) { CommandTimeout = 0 };
+ await command.ExecuteNonQueryAsync(token).ConfigureAwait(false);
+ created++;
+ }
+ catch (NpgsqlException ex) when (!token.IsCancellationRequested)
+ {
+ // A failed concurrent build leaves the index in the catalog, marked invalid. The failure may have taken the session with it, so the catalog is read on a new one.
+ await using var other = new NpgsqlConnection(connectionString);
+ await other.OpenAsync(CancellationToken.None).ConfigureAwait(false);
+ await using var check = new NpgsqlCommand("SELECT EXISTS (SELECT 1 FROM pg_catalog.pg_index i JOIN pg_catalog.pg_class c ON c.oid = i.indexrelid JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace WHERE n.nspname = @schema AND c.relname = @name AND NOT i.indisvalid)", other) { CommandTimeout = 30 };
+ check.Parameters.AddWithValue("schema", index.Key.Schema); check.Parameters.AddWithValue("name", index.IndexName);
+ bool invalid = (bool)(await check.ExecuteScalarAsync(CancellationToken.None).ConfigureAwait(false))!;
+ return new(created, ex is PostgresException pg ? $"{pg.SqlState}: {pg.MessageText}" : ex.Message, invalid ? index.IndexName : null, invalid ? index.Key.Schema : null);
+ }
+ }
+ return new(created, null, null);
+ }
+
+ /// Removes an invalid index left by a failed concurrent build.
+ public static string DropInvalid(string schema, string index) => $"DROP INDEX CONCURRENTLY {Id(schema)}.{Id(index)};";
+}
diff --git a/tests/PGMS.DesktopTests/DatabaseDiagramChecks.cs b/tests/PGMS.DesktopTests/DatabaseDiagramChecks.cs
index 6a067be..4e00bd5 100644
--- a/tests/PGMS.DesktopTests/DatabaseDiagramChecks.cs
+++ b/tests/PGMS.DesktopTests/DatabaseDiagramChecks.cs
@@ -347,7 +347,7 @@ async Task Menu(CatalogEntry entry)
check(view.SelectAt(middle) is DiagramRelation && view.SelectedRelation is not null && view.Lines.Children.Contains(hitLine)
&& view.Lines.Children.OfType().Any(p => p.Tag is null && p.Stroke is SolidColorBrush { Color: { R: 0x00, G: 0x78, B: 0xD7 } }), "Clicking a line selects it in blue and keeps the clicked line, so its context menu opens (found by mouse piloting)");
check(Items(view.Relations.Single()).SequenceEqual(["DiagramDeleteRelationship", "DiagramRelationshipProperties"]), "Relationship menu: Delete Relationships from Database, Properties");
- check(Items(null).SequenceEqual(["DiagramNewTable", "DiagramAddTable", "DiagramNewAnnotation", "DiagramSelectAll", "DiagramShowLabels", "DiagramArrange", "DiagramZoom", "DiagramViewPageBreaks", "DiagramRecalculatePageBreaks", "DiagramCopy"]), "Empty area menu follows SSMS (capture m4), without Paste");
+ check(Items(null).SequenceEqual(["DiagramNewTable", "DiagramAddTable", "DiagramNewAnnotation", "DiagramSelectAll", "DiagramShowLabels", "DiagramShowIndexes", "DiagramArrange", "DiagramZoom", "DiagramViewPageBreaks", "DiagramRecalculatePageBreaks", "DiagramCopy", "DiagramRefresh"]), "Empty area menu follows SSMS (capture m4), without Paste, plus Show Indexes and Refresh of PGMS-044");
// Ctrl: the hand drags the visible area; Ctrl + wheel zooms (pedidos do usuário de 07/11/2026).
var scroller = (ScrollViewer)view.FindName("Scroller");
@@ -800,6 +800,7 @@ MouseButtonEventArgs Click(int count)
window.DiagramNamePrompt = (_, _) => "Renamed";
check(await window.RenameDiagramAsync(connection, id, "Quarterly"), "The diagram name goes back for the next checks");
await DiagramEditingChecks.RunAsync(window, connection, check);
+ await DiagramIndexUiChecks.RunAsync(window, connection, check);
await ViewDesignerChecks.RunAsync(window, connection, check);
await QueryDesignerChecks.RunAsync(window, connection, check);
check(await window.DeleteDiagramAsync(connection, id, "Renamed") && (await service.ListAsync()).Count == 0 && await Scalar("SELECT to_regclass('public.customers') IS NOT NULL") is true, "Delete removes the diagram and keeps its tables");
diff --git a/tests/PGMS.DesktopTests/DiagramIndexUiChecks.cs b/tests/PGMS.DesktopTests/DiagramIndexUiChecks.cs
new file mode 100644
index 0000000..e3805b5
--- /dev/null
+++ b/tests/PGMS.DesktopTests/DiagramIndexUiChecks.cs
@@ -0,0 +1,185 @@
+using System.Text.Json;
+using System.Windows;
+using System.Windows.Automation;
+using System.Windows.Controls;
+using System.Windows.Documents;
+using System.Windows.Media;
+using System.Windows.Threading;
+using Npgsql;
+using PGMS.Desktop;
+using PGMS.PostgreSql;
+
+/// Indexes in the diagram tab (PGMS-044, M1061): the Indexes section, Show Indexes, the warning of foreign keys without an index, Refresh,
+/// and the indexes kept by diagram. Runs in the throwaway database of .
+internal static class DiagramIndexUiChecks
+{
+ internal static async Task RunAsync(MainWindow window, string connection, Action check)
+ {
+ await using var admin = new NpgsqlConnection(connection);
+ await admin.OpenAsync();
+ async Task Sql(string sql) { await using var command = new NpgsqlCommand(sql, admin); await command.ExecuteNonQueryAsync(); }
+ async Task