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 @@ + + + + + + +