From c526b62d66232a71b32004a766813a7ca8273286 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Nguy=E1=BB=85n=20Nam=20Long?=
<26674308+J2TeamNNL@users.noreply.github.com>
Date: Sun, 1 Nov 2026 03:34:40 +0700
Subject: [PATCH 01/10] feat(table-health): report table and index health per
connection
---
.../Tools/MCPTabSnapshotProvider.swift | 1 +
.../Core/Menu/DatabaseMenuBuilder.swift | 4 +
.../Core/Scripting/ScriptEnumerations.swift | 1 +
.../Infrastructure/EditorTabOpener.swift | 2 +
...itViewController+DatabaseMenuActions.swift | 4 +
...inSplitViewController+MenuValidation.swift | 2 +-
.../MainSplitViewController+RecentTabs.swift | 2 +
.../MainSplitViewController.swift | 4 +-
.../Toolbar/ToolbarContextResolver.swift | 6 +-
.../Infrastructure/WindowTitleResolver.swift | 2 +
.../MySQLTableHealthProvider.swift | 223 +++++++++++++++++
.../PostgreSQLTableHealthProvider.swift | 212 ++++++++++++++++
.../TableHealth/TableHealthProvider.swift | 88 +++++++
.../Models/Query/QueryTabManager.swift | 14 +-
.../TablePro/Models/Query/QueryTabState.swift | 1 +
.../TableHealth/TableHealthModels.swift | 71 ++++++
.../Models/UI/GridSelectionOwner.swift | 4 +-
.../Models/UI/PendingChangeKind.swift | 4 +-
.../TablePro/Resources/Localizable.xcstrings | 81 ++++++
apple/TablePro/Resources/TablePro.sdef | 1 +
.../ViewModels/TableHealthViewModel.swift | 94 +++++++
.../Main/Child/MainEditorContentView.swift | 25 ++
.../MainContentCoordinator+TableHealth.swift | 44 ++++
...nContentCommandActions+DatabaseTools.swift | 4 +
.../Views/TableHealth/TableHealthView.swift | 224 +++++++++++++++++
.../RoutineDebugToolbarTests.swift | 4 +-
.../Scripting/ScriptingDictionaryTests.swift | 2 +-
.../EditorTabConnectionMovePolicyTests.swift | 2 +-
.../MySQLTableHealthProviderTests.swift | 159 ++++++++++++
.../PostgreSQLTableHealthProviderTests.swift | 145 +++++++++++
.../TableHealth/TableHealthTestSupport.swift | 21 ++
.../TableHealthViewModelTests.swift | 121 +++++++++
.../probes/check-table-health-catalogs.sh | 234 ++++++++++++++++++
33 files changed, 1791 insertions(+), 15 deletions(-)
create mode 100644 apple/TablePro/Core/TableHealth/MySQLTableHealthProvider.swift
create mode 100644 apple/TablePro/Core/TableHealth/PostgreSQLTableHealthProvider.swift
create mode 100644 apple/TablePro/Core/TableHealth/TableHealthProvider.swift
create mode 100644 apple/TablePro/Models/TableHealth/TableHealthModels.swift
create mode 100644 apple/TablePro/ViewModels/TableHealthViewModel.swift
create mode 100644 apple/TablePro/Views/Main/Extensions/MainContentCoordinator+TableHealth.swift
create mode 100644 apple/TablePro/Views/TableHealth/TableHealthView.swift
create mode 100644 apple/TableProTests/Core/TableHealth/MySQLTableHealthProviderTests.swift
create mode 100644 apple/TableProTests/Core/TableHealth/PostgreSQLTableHealthProviderTests.swift
create mode 100644 apple/TableProTests/Core/TableHealth/TableHealthTestSupport.swift
create mode 100644 apple/TableProTests/ViewModels/TableHealthViewModelTests.swift
create mode 100755 apple/scripts/probes/check-table-health-catalogs.sh
diff --git a/apple/TablePro/Core/MCP/Protocol/Tools/MCPTabSnapshotProvider.swift b/apple/TablePro/Core/MCP/Protocol/Tools/MCPTabSnapshotProvider.swift
index a52f02125a..20886d5950 100644
--- a/apple/TablePro/Core/MCP/Protocol/Tools/MCPTabSnapshotProvider.swift
+++ b/apple/TablePro/Core/MCP/Protocol/Tools/MCPTabSnapshotProvider.swift
@@ -166,6 +166,7 @@ private extension TabType {
case .erDiagram: "erDiagram"
case .serverDashboard: "serverDashboard"
case .insights: "insights"
+ case .tableHealth: "tableHealth"
case .usersRoles: "usersRoles"
case .objectSource: "objectSource"
case .versionHistory: "versionHistory"
diff --git a/apple/TablePro/Core/Menu/DatabaseMenuBuilder.swift b/apple/TablePro/Core/Menu/DatabaseMenuBuilder.swift
index 44c9fe5afa..e165f2b8f7 100644
--- a/apple/TablePro/Core/Menu/DatabaseMenuBuilder.swift
+++ b/apple/TablePro/Core/Menu/DatabaseMenuBuilder.swift
@@ -143,6 +143,10 @@ enum DatabaseMenuBuilder {
String(localized: "Query Insights"),
action: #selector(MainSplitViewController.showQueryInsights(_:))
),
+ MenuItemFactory.item(
+ String(localized: "Table Health"),
+ action: #selector(MainSplitViewController.showTableHealth(_:))
+ ),
MenuItemFactory.separator,
/// No ellipsis: the HIG reserves one for an action that needs more information before
/// it can complete, and this needs none. It validates to disabled for every connection
diff --git a/apple/TablePro/Core/Scripting/ScriptEnumerations.swift b/apple/TablePro/Core/Scripting/ScriptEnumerations.swift
index 98af280154..70707a1a68 100644
--- a/apple/TablePro/Core/Scripting/ScriptEnumerations.swift
+++ b/apple/TablePro/Core/Scripting/ScriptEnumerations.swift
@@ -42,6 +42,7 @@ internal enum ScriptEnumerations {
case .serverDashboard: fourCharCode("TPk5")
case .usersRoles: fourCharCode("TPk6")
case .insights: fourCharCode("TPk7")
+ case .tableHealth: fourCharCode("TPkx")
case .objectSource: fourCharCode("TPk8")
case .versionHistory: fourCharCode("TPk9")
case .tableListing: fourCharCode("TPkl")
diff --git a/apple/TablePro/Core/Services/Infrastructure/EditorTabOpener.swift b/apple/TablePro/Core/Services/Infrastructure/EditorTabOpener.swift
index ff7903361e..0256ad78c0 100644
--- a/apple/TablePro/Core/Services/Infrastructure/EditorTabOpener.swift
+++ b/apple/TablePro/Core/Services/Infrastructure/EditorTabOpener.swift
@@ -96,6 +96,8 @@ internal enum EditorTabOpener {
tabManager.addUsersRolesTab()
case .insights:
tabManager.addQueryInsightsTab()
+ case .tableHealth:
+ tabManager.addTableHealthTab()
case .objectSource:
guard let objectRef = payload.objectRef else { return }
tabManager.addObjectSourceTab(objectRef: objectRef)
diff --git a/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+DatabaseMenuActions.swift b/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+DatabaseMenuActions.swift
index 0211db530c..dbeb02d0b8 100644
--- a/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+DatabaseMenuActions.swift
+++ b/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+DatabaseMenuActions.swift
@@ -252,4 +252,8 @@ extension MainSplitViewController {
@objc func showQueryInsights(_ sender: Any?) {
commandActions?.showQueryInsights()
}
+
+ @objc func showTableHealth(_ sender: Any?) {
+ commandActions?.showTableHealth()
+ }
}
diff --git a/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift b/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift
index f480747647..5f123e2895 100644
--- a/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift
+++ b/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift
@@ -285,7 +285,7 @@ extension MainSplitViewController: NSMenuItemValidation {
return context.isConnected && context.supportsServerDashboard
case #selector(showUsersAndRoles(_:)):
return context.isConnected && context.supportsUserManagement
- case #selector(showQueryInsights(_:)):
+ case #selector(showQueryInsights(_:)), #selector(showTableHealth(_:)):
return context.isConnected
case #selector(toggleFilterBar(_:)):
diff --git a/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+RecentTabs.swift b/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+RecentTabs.swift
index a77ad56643..ac82152aa8 100644
--- a/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+RecentTabs.swift
+++ b/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+RecentTabs.swift
@@ -207,6 +207,8 @@ internal extension MainSplitViewController {
return "person.2"
case .insights:
return "chart.bar"
+ case .tableHealth:
+ return "stethoscope"
case .objectSource:
return "curlybraces.square"
case .versionHistory:
diff --git a/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift b/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift
index f05c1dfc92..46aa54f0fb 100644
--- a/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift
+++ b/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift
@@ -1385,8 +1385,8 @@ internal final class MainSplitViewController: NSSplitViewController {
switch tabType {
case .usersRoles:
return UsersRolesLayoutMetrics.tabMinimumWidth
- case .query, .table, .createTable, .erDiagram, .serverDashboard, .insights, .objectSource, .versionHistory,
- .tableListing, .routineDebug:
+ case .query, .table, .createTable, .erDiagram, .serverDashboard, .insights, .tableHealth, .objectSource,
+ .versionHistory, .tableListing, .routineDebug:
return defaultDetailMinThickness
}
}
diff --git a/apple/TablePro/Core/Services/Infrastructure/Toolbar/ToolbarContextResolver.swift b/apple/TablePro/Core/Services/Infrastructure/Toolbar/ToolbarContextResolver.swift
index 5fb912893c..24da7edd8b 100644
--- a/apple/TablePro/Core/Services/Infrastructure/Toolbar/ToolbarContextResolver.swift
+++ b/apple/TablePro/Core/Services/Infrastructure/Toolbar/ToolbarContextResolver.swift
@@ -69,8 +69,8 @@ internal enum ToolbarContextResolver {
String(localized: "Create Table")
case .usersRoles:
String(localized: "Apply Changes")
- case .query, .table, .erDiagram, .serverDashboard, .insights, .objectSource, .versionHistory, .tableListing,
- .routineDebug, nil:
+ case .query, .table, .erDiagram, .serverDashboard, .insights, .tableHealth, .objectSource, .versionHistory,
+ .tableListing, .routineDebug, nil:
String(localized: "Save Changes")
}
}
@@ -110,7 +110,7 @@ internal enum ToolbarContextResolver {
return [MainWindowToolbar.refresh]
case .objectSource where key.stagesObjectChanges:
return []
- case .serverDashboard, .insights, .objectSource, .versionHistory, .tableListing:
+ case .serverDashboard, .insights, .tableHealth, .objectSource, .versionHistory, .tableListing:
/// None of these can stage a change, so the commit control could only ever be dim.
return [MainWindowToolbar.saveChanges]
case .routineDebug:
diff --git a/apple/TablePro/Core/Services/Infrastructure/WindowTitleResolver.swift b/apple/TablePro/Core/Services/Infrastructure/WindowTitleResolver.swift
index 372c2f8d78..06d0bc89a0 100644
--- a/apple/TablePro/Core/Services/Infrastructure/WindowTitleResolver.swift
+++ b/apple/TablePro/Core/Services/Infrastructure/WindowTitleResolver.swift
@@ -144,6 +144,8 @@ enum WindowTitleResolver {
return String(localized: "Users & Roles")
case .insights:
return String(localized: "Query Insights")
+ case .tableHealth:
+ return String(localized: "Table Health")
case .erDiagram:
return String(localized: "ER Diagram")
case .createTable:
diff --git a/apple/TablePro/Core/TableHealth/MySQLTableHealthProvider.swift b/apple/TablePro/Core/TableHealth/MySQLTableHealthProvider.swift
new file mode 100644
index 0000000000..e9ad0d477e
--- /dev/null
+++ b/apple/TablePro/Core/TableHealth/MySQLTableHealthProvider.swift
@@ -0,0 +1,223 @@
+//
+// MySQLTableHealthProvider.swift
+// TablePro
+//
+
+import Foundation
+
+/// Reads every user schema on the server. InnoDB indexes a foreign key's columns itself, so there
+/// is no unindexed foreign key check. `scripts/probes/check-table-health-catalogs.sh` runs these
+/// queries against a real server, so keep each one a plain literal with no interpolation.
+struct MySQLTableHealthProvider: TableHealthProvider {
+ let checks: [TableHealthCheckKind] = [.unusedIndex, .redundantIndex, .missingPrimaryKey]
+ let schemaNamesDatabase = true
+
+ // The table sys.schema_unused_indexes reads, queried directly since MariaDB before 10.6 has no sys
+ // schema. A unique index enforces something even when no query reads it.
+ static let unusedIndexQuery = """
+ SELECT u.OBJECT_SCHEMA AS schema_name,
+ u.OBJECT_NAME AS table_name,
+ u.INDEX_NAME AS index_name
+ FROM performance_schema.table_io_waits_summary_by_index_usage u
+ WHERE u.INDEX_NAME IS NOT NULL
+ AND u.INDEX_NAME <> 'PRIMARY'
+ AND u.COUNT_STAR = 0
+ AND u.OBJECT_SCHEMA NOT IN ('mysql', 'sys', 'performance_schema', 'information_schema')
+ AND NOT EXISTS (
+ SELECT 1 FROM information_schema.STATISTICS s
+ WHERE s.TABLE_SCHEMA = u.OBJECT_SCHEMA
+ AND s.TABLE_NAME = u.OBJECT_NAME
+ AND s.INDEX_NAME = u.INDEX_NAME
+ AND s.NON_UNIQUE = 0
+ )
+ ORDER BY u.OBJECT_SCHEMA, u.OBJECT_NAME, u.INDEX_NAME
+ LIMIT 500
+ """
+
+ // The prefix comparison sys.schema_redundant_indexes makes, done in Swift so it needs no sys schema.
+ static let redundantIndexQuery = """
+ SELECT s.TABLE_SCHEMA AS schema_name,
+ s.TABLE_NAME AS table_name,
+ s.INDEX_NAME AS index_name,
+ s.NON_UNIQUE AS non_unique,
+ s.INDEX_TYPE AS index_type,
+ s.COLUMN_NAME AS column_name,
+ s.SUB_PART AS sub_part
+ FROM information_schema.STATISTICS s
+ WHERE s.TABLE_SCHEMA NOT IN ('mysql', 'sys', 'performance_schema', 'information_schema')
+ ORDER BY s.TABLE_SCHEMA, s.TABLE_NAME, s.INDEX_NAME, s.SEQ_IN_INDEX
+ """
+
+ static let missingPrimaryKeyQuery = """
+ SELECT t.TABLE_SCHEMA AS schema_name,
+ t.TABLE_NAME AS table_name
+ FROM information_schema.TABLES t
+ WHERE t.TABLE_TYPE = 'BASE TABLE'
+ AND t.TABLE_SCHEMA NOT IN ('mysql', 'sys', 'performance_schema', 'information_schema')
+ AND NOT EXISTS (
+ SELECT 1 FROM information_schema.TABLE_CONSTRAINTS c
+ WHERE c.TABLE_SCHEMA = t.TABLE_SCHEMA
+ AND c.TABLE_NAME = t.TABLE_NAME
+ AND c.CONSTRAINT_TYPE = 'PRIMARY KEY'
+ )
+ ORDER BY t.TABLE_SCHEMA, t.TABLE_NAME
+ LIMIT 500
+ """
+
+ static let performanceSchemaQuery = "SELECT @@GLOBAL.performance_schema AS performance_schema"
+
+ static let uptimeQuery = "SHOW GLOBAL STATUS LIKE 'Uptime'"
+
+ var statisticsQueries: [String] { [Self.performanceSchemaQuery, Self.uptimeQuery] }
+
+ func query(for check: TableHealthCheckKind) -> String {
+ switch check {
+ case .unusedIndex: return Self.unusedIndexQuery
+ case .redundantIndex: return Self.redundantIndexQuery
+ case .missingPrimaryKey: return Self.missingPrimaryKeyQuery
+ case .bloat, .unindexedForeignKey: return ""
+ }
+ }
+
+ func findings(for check: TableHealthCheckKind, from result: QueryResult) -> [TableHealthFinding] {
+ let rows = TableHealthRow.rows(of: result)
+ switch check {
+ case .unusedIndex:
+ return rows.map(unusedIndexFinding)
+ case .redundantIndex:
+ return redundantIndexFindings(rows)
+ case .missingPrimaryKey:
+ return rows.map(missingPrimaryKeyFinding)
+ case .bloat, .unindexedForeignKey:
+ return []
+ }
+ }
+
+ /// Performance Schema counts index reads from the server start, and records none while it is off.
+ func statisticsNote(from results: [QueryResult], now: Date) -> String? {
+ guard results.count == 2 else { return nil }
+ let enabled = TableHealthRow.rows(of: results[0]).first?["performance_schema"] ?? ""
+ guard enabled == "1" || enabled.uppercased() == "ON" else {
+ return String(localized: "Performance Schema is off, so the server records no index usage and no index can show as unused.")
+ }
+ guard let uptime = TableHealthRow.rows(of: results[1]).first.flatMap({ Double($0["value"]) }) else { return nil }
+ let start = now.addingTimeInterval(-uptime).formatted(date: .abbreviated, time: .shortened)
+ return String(format: String(localized: "Index usage counts since the server started at %@."), start)
+ }
+
+ // MARK: - Fix SQL
+
+ static func dropIndexSQL(schema: String, table: String, index: String) -> String {
+ "ALTER TABLE \(qualified(schema, table)) DROP INDEX \(quote(index));"
+ }
+
+ static func addPrimaryKeySQL(schema: String, table: String) -> String {
+ "ALTER TABLE \(qualified(schema, table)) ADD PRIMARY KEY (column_name);"
+ }
+
+ static func quote(_ identifier: String) -> String {
+ "`" + identifier.replacingOccurrences(of: "`", with: "``") + "`"
+ }
+
+ private static func qualified(_ schema: String, _ name: String) -> String {
+ "\(quote(schema)).\(quote(name))"
+ }
+
+ // MARK: - Parsing
+
+ private func unusedIndexFinding(_ row: TableHealthRow) -> TableHealthFinding {
+ let schema = row["schema_name"], table = row["table_name"], index = row["index_name"]
+ return TableHealthFinding(
+ kind: .unusedIndex, schema: schema, table: table, objectName: index,
+ detail: String(format: String(localized: "%@ has no recorded reads."), index),
+ fixSQL: Self.dropIndexSQL(schema: schema, table: table, index: index)
+ )
+ }
+
+ private func redundantIndexFindings(_ rows: [TableHealthRow]) -> [TableHealthFinding] {
+ var findings: [TableHealthFinding] = []
+ for table in MySQLIndexShape.tables(from: rows) {
+ for index in table.indexes {
+ guard let dominant = table.indexes.first(where: { index.isRedundant(to: $0) }) else { continue }
+ findings.append(TableHealthFinding(
+ kind: .redundantIndex, schema: table.schema, table: table.name, objectName: index.name,
+ detail: String(format: String(localized: "%1$@ is covered by %2$@."), index.name, dominant.name),
+ fixSQL: Self.dropIndexSQL(schema: table.schema, table: table.name, index: index.name)
+ ))
+ if findings.count == TableHealthCheckResult.findingLimit { return findings }
+ }
+ }
+ return findings
+ }
+
+ private func missingPrimaryKeyFinding(_ row: TableHealthRow) -> TableHealthFinding {
+ let schema = row["schema_name"], table = row["table_name"]
+ return TableHealthFinding(
+ kind: .missingPrimaryKey, schema: schema, table: table, objectName: nil,
+ detail: String(localized: "No primary key. Replace column_name with the columns that identify a row."),
+ fixSQL: Self.addPrimaryKeySQL(schema: schema, table: table)
+ )
+ }
+}
+
+/// The indexes of one table, read from `information_schema.STATISTICS` rows ordered by index and position.
+struct MySQLIndexShape {
+ struct Column: Equatable {
+ let name: String
+ let prefixLength: String
+ }
+
+ struct Index {
+ let name: String
+ let isUnique: Bool
+ let type: String
+ var columns: [Column]
+ /// A functional key part has no column name and cannot be compared by prefix.
+ var hasExpression: Bool
+
+ var isPrimary: Bool { name == "PRIMARY" }
+
+ /// Redundant when another index of the same type starts with the same columns. Of two equal
+ /// indexes, the unique one stays; of two equal unique or two equal plain ones, the first by name stays.
+ func isRedundant(to other: Index) -> Bool {
+ guard other.name != name, !isPrimary, !hasExpression, !other.hasExpression,
+ other.type == type, columns.count <= other.columns.count,
+ Array(other.columns.prefix(columns.count)) == columns
+ else { return false }
+ if columns.count < other.columns.count { return !isUnique }
+ if isUnique != other.isUnique { return other.isUnique }
+ return other.isPrimary || other.name < name
+ }
+ }
+
+ let schema: String
+ let name: String
+ var indexes: [Index]
+
+ static func tables(from rows: [TableHealthRow]) -> [MySQLIndexShape] {
+ var tables: [MySQLIndexShape] = []
+ for row in rows {
+ let schema = row["schema_name"], table = row["table_name"], indexName = row["index_name"]
+ if tables.last?.schema != schema || tables.last?.name != table {
+ tables.append(MySQLIndexShape(schema: schema, name: table, indexes: []))
+ }
+ let column = row["column_name"]
+ if tables[tables.count - 1].indexes.last?.name != indexName {
+ tables[tables.count - 1].indexes.append(Index(
+ name: indexName,
+ isUnique: row["non_unique"] == "0",
+ type: row["index_type"],
+ columns: [],
+ hasExpression: false
+ ))
+ }
+ let last = tables[tables.count - 1].indexes.count - 1
+ if column.isEmpty {
+ tables[tables.count - 1].indexes[last].hasExpression = true
+ } else {
+ tables[tables.count - 1].indexes[last].columns.append(Column(name: column, prefixLength: row["sub_part"]))
+ }
+ }
+ return tables
+ }
+}
diff --git a/apple/TablePro/Core/TableHealth/PostgreSQLTableHealthProvider.swift b/apple/TablePro/Core/TableHealth/PostgreSQLTableHealthProvider.swift
new file mode 100644
index 0000000000..8d0f342603
--- /dev/null
+++ b/apple/TablePro/Core/TableHealth/PostgreSQLTableHealthProvider.swift
@@ -0,0 +1,212 @@
+//
+// PostgreSQLTableHealthProvider.swift
+// TablePro
+//
+
+import Foundation
+
+/// Reads the current database. `scripts/probes/check-table-health-catalogs.sh` runs these queries
+/// against a real server, so keep each one a plain literal with no interpolation.
+struct PostgreSQLTableHealthProvider: TableHealthProvider {
+ let checks: [TableHealthCheckKind] = [.bloat, .unusedIndex, .missingPrimaryKey, .unindexedForeignKey]
+ let schemaNamesDatabase = false
+
+ // Autovacuum's own trigger is 20% dead rows plus 50; the floor keeps tiny tables out.
+ static let bloatQuery = """
+ SELECT schemaname AS schema_name,
+ relname AS table_name,
+ n_live_tup AS live_rows,
+ n_dead_tup AS dead_rows,
+ pg_catalog.date_trunc('second', GREATEST(last_vacuum, last_autovacuum))::pg_catalog.text AS last_vacuum
+ FROM pg_catalog.pg_stat_user_tables
+ WHERE n_dead_tup >= 1000 AND n_dead_tup >= n_live_tup / 5
+ ORDER BY n_dead_tup DESC, schemaname, relname
+ LIMIT 500
+ """
+
+ // A unique or constraint index enforces something even when no query reads it.
+ static let unusedIndexQuery = """
+ SELECT s.schemaname AS schema_name,
+ s.relname AS table_name,
+ s.indexrelname AS index_name,
+ pg_catalog.pg_relation_size(s.indexrelid) AS index_bytes
+ FROM pg_catalog.pg_stat_user_indexes s
+ JOIN pg_catalog.pg_index i ON i.indexrelid = s.indexrelid
+ WHERE s.idx_scan = 0
+ AND NOT i.indisunique
+ AND NOT EXISTS (SELECT 1 FROM pg_catalog.pg_constraint c WHERE c.conindid = s.indexrelid)
+ ORDER BY index_bytes DESC, schema_name, index_name
+ LIMIT 500
+ """
+
+ static let missingPrimaryKeyQuery = """
+ SELECT n.nspname AS schema_name,
+ c.relname AS table_name
+ FROM pg_catalog.pg_class c
+ JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
+ WHERE c.relkind IN ('r', 'p')
+ AND n.nspname <> 'information_schema'
+ AND pg_catalog.left(n.nspname, 3) <> 'pg_'
+ AND NOT EXISTS (
+ SELECT 1 FROM pg_catalog.pg_constraint con
+ WHERE con.conrelid = c.oid AND con.contype = 'p'
+ )
+ ORDER BY n.nspname, c.relname
+ LIMIT 500
+ """
+
+ // One row per referencing column. An index covers the key when its leading columns are the
+ // key's columns in any order; a partial index does not, since it skips rows.
+ static let unindexedForeignKeyQuery = """
+ SELECT n.nspname AS schema_name,
+ c.relname AS table_name,
+ con.conname AS constraint_name,
+ a.attname AS column_name
+ 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
+ CROSS JOIN LATERAL pg_catalog.unnest(con.conkey) WITH ORDINALITY AS k(attnum, position)
+ JOIN pg_catalog.pg_attribute a ON a.attrelid = con.conrelid AND a.attnum = k.attnum
+ WHERE con.contype = 'f'
+ AND NOT EXISTS (
+ SELECT 1 FROM pg_catalog.pg_index i
+ WHERE i.indrelid = con.conrelid
+ AND i.indpred IS NULL
+ AND (i.indkey::pg_catalog.int2[])[0:pg_catalog.array_length(con.conkey, 1) - 1] @> con.conkey
+ )
+ ORDER BY n.nspname, c.relname, con.conname, k.position
+ """
+
+ static let statisticsResetQuery = """
+ SELECT pg_catalog.date_trunc('second', stats_reset)::pg_catalog.text AS stats_reset
+ FROM pg_catalog.pg_stat_database
+ WHERE datname = pg_catalog.current_database()
+ """
+
+ var statisticsQueries: [String] { [Self.statisticsResetQuery] }
+
+ func query(for check: TableHealthCheckKind) -> String {
+ switch check {
+ case .bloat: return Self.bloatQuery
+ case .unusedIndex: return Self.unusedIndexQuery
+ case .missingPrimaryKey: return Self.missingPrimaryKeyQuery
+ case .unindexedForeignKey: return Self.unindexedForeignKeyQuery
+ case .redundantIndex: return ""
+ }
+ }
+
+ func findings(for check: TableHealthCheckKind, from result: QueryResult) -> [TableHealthFinding] {
+ let rows = TableHealthRow.rows(of: result)
+ switch check {
+ case .bloat:
+ return rows.map(bloatFinding)
+ case .unusedIndex:
+ return rows.map(unusedIndexFinding)
+ case .missingPrimaryKey:
+ return rows.map(missingPrimaryKeyFinding)
+ case .unindexedForeignKey:
+ return unindexedForeignKeyFindings(rows)
+ case .redundantIndex:
+ return []
+ }
+ }
+
+ func statisticsNote(from results: [QueryResult], now: Date) -> String? {
+ guard let result = results.first, let row = TableHealthRow.rows(of: result).first else { return nil }
+ let reset = row["stats_reset"]
+ guard !reset.isEmpty else {
+ return String(localized: "Index usage counts since the statistics were first collected; they have never been reset.")
+ }
+ return String(format: String(localized: "Index usage counts since the statistics were last reset at %@."), reset)
+ }
+
+ // MARK: - Fix SQL
+
+ static func vacuumSQL(schema: String, table: String) -> String {
+ "VACUUM (ANALYZE) \(qualified(schema, table));"
+ }
+
+ static func dropIndexSQL(schema: String, index: String) -> String {
+ "DROP INDEX CONCURRENTLY \(qualified(schema, index));"
+ }
+
+ static func addPrimaryKeySQL(schema: String, table: String) -> String {
+ "ALTER TABLE \(qualified(schema, table)) ADD PRIMARY KEY (column_name);"
+ }
+
+ static func createIndexSQL(schema: String, table: String, columns: [String]) -> String {
+ let columnList = columns.map(SQLEscaping.quoteIdentifier).joined(separator: ", ")
+ return "CREATE INDEX CONCURRENTLY ON \(qualified(schema, table)) (\(columnList));"
+ }
+
+ private static func qualified(_ schema: String, _ name: String) -> String {
+ "\(SQLEscaping.quoteIdentifier(schema)).\(SQLEscaping.quoteIdentifier(name))"
+ }
+
+ // MARK: - Parsing
+
+ private func bloatFinding(_ row: TableHealthRow) -> TableHealthFinding {
+ let schema = row["schema_name"], table = row["table_name"]
+ let dead = TableHealthFormatting.count(row["dead_rows"])
+ let live = TableHealthFormatting.count(row["live_rows"])
+ let lastVacuum = row["last_vacuum"]
+ let detail = lastVacuum.isEmpty
+ ? String(format: String(localized: "%1$@ dead rows, %2$@ live. Never vacuumed."), dead, live)
+ : String(format: String(localized: "%1$@ dead rows, %2$@ live. Last vacuumed %3$@."), dead, live, lastVacuum)
+ return TableHealthFinding(
+ kind: .bloat, schema: schema, table: table, objectName: nil,
+ detail: detail, fixSQL: Self.vacuumSQL(schema: schema, table: table)
+ )
+ }
+
+ private func unusedIndexFinding(_ row: TableHealthRow) -> TableHealthFinding {
+ let schema = row["schema_name"], index = row["index_name"]
+ let size = ByteSizeFormatting.string(byteString: row["index_bytes"])
+ return TableHealthFinding(
+ kind: .unusedIndex, schema: schema, table: row["table_name"], objectName: index,
+ detail: String(format: String(localized: "%1$@ has never been scanned. It takes %2$@."), index, size),
+ fixSQL: Self.dropIndexSQL(schema: schema, index: index)
+ )
+ }
+
+ private func missingPrimaryKeyFinding(_ row: TableHealthRow) -> TableHealthFinding {
+ let schema = row["schema_name"], table = row["table_name"]
+ return TableHealthFinding(
+ kind: .missingPrimaryKey, schema: schema, table: table, objectName: nil,
+ detail: String(localized: "No primary key. Replace column_name with the columns that identify a row."),
+ fixSQL: Self.addPrimaryKeySQL(schema: schema, table: table)
+ )
+ }
+
+ private func unindexedForeignKeyFindings(_ rows: [TableHealthRow]) -> [TableHealthFinding] {
+ var order: [String] = []
+ var grouped: [String: (schema: String, table: String, constraint: String, columns: [String])] = [:]
+ for row in rows {
+ let key = [row["schema_name"], row["table_name"], row["constraint_name"]].joined(separator: "\u{1F}")
+ if grouped[key] == nil {
+ order.append(key)
+ grouped[key] = (row["schema_name"], row["table_name"], row["constraint_name"], [])
+ }
+ grouped[key]?.columns.append(row["column_name"])
+ }
+ return order.prefix(TableHealthCheckResult.findingLimit).compactMap { key in
+ guard let key = grouped[key] else { return nil }
+ return TableHealthFinding(
+ kind: .unindexedForeignKey, schema: key.schema, table: key.table, objectName: key.constraint,
+ detail: String(
+ format: String(localized: "%1$@ on (%2$@) has no index, so deletes and updates on the referenced table scan this one."),
+ key.constraint,
+ key.columns.joined(separator: ", ")
+ ),
+ fixSQL: Self.createIndexSQL(schema: key.schema, table: key.table, columns: key.columns)
+ )
+ }
+ }
+}
+
+enum TableHealthFormatting {
+ static func count(_ text: String) -> String {
+ guard let value = Int64(text) else { return text }
+ return value.formatted()
+ }
+}
diff --git a/apple/TablePro/Core/TableHealth/TableHealthProvider.swift b/apple/TablePro/Core/TableHealth/TableHealthProvider.swift
new file mode 100644
index 0000000000..5d7fc9303b
--- /dev/null
+++ b/apple/TablePro/Core/TableHealth/TableHealthProvider.swift
@@ -0,0 +1,88 @@
+//
+// TableHealthProvider.swift
+// TablePro
+//
+
+import Foundation
+import TableProPluginKit
+
+/// Reads one engine's catalogs into table and index health findings. Every query only reads;
+/// the fixes it builds are text for the user to review.
+protocol TableHealthProvider: Sendable {
+ var checks: [TableHealthCheckKind] { get }
+ /// MySQL calls a database a schema, so a finding's schema is the database its table opens in.
+ var schemaNamesDatabase: Bool { get }
+ var statisticsQueries: [String] { get }
+ func query(for check: TableHealthCheckKind) -> String
+ func findings(for check: TableHealthCheckKind, from result: QueryResult) -> [TableHealthFinding]
+ func statisticsNote(from results: [QueryResult], now: Date) -> String?
+}
+
+extension TableHealthProvider {
+ /// One failing check (a privilege, Performance Schema access) leaves the others standing.
+ @MainActor
+ func fetchReport(
+ execute: @MainActor (String) async throws -> QueryResult,
+ now: Date = Date()
+ ) async throws -> TableHealthReport {
+ var results: [TableHealthCheckResult] = []
+ for check in checks {
+ do {
+ let result = try await execute(query(for: check))
+ results.append(TableHealthCheckResult(kind: check, findings: findings(for: check, from: result)))
+ } catch is CancellationError {
+ throw CancellationError()
+ } catch {
+ results.append(TableHealthCheckResult(kind: check, findings: [], error: error.localizedDescription))
+ }
+ }
+
+ var statisticsResults: [QueryResult] = []
+ do {
+ for query in statisticsQueries {
+ statisticsResults.append(try await execute(query))
+ }
+ } catch is CancellationError {
+ throw CancellationError()
+ } catch {
+ statisticsResults = []
+ }
+ let note = statisticsResults.count == statisticsQueries.count
+ ? statisticsNote(from: statisticsResults, now: now)
+ : nil
+
+ return TableHealthReport(checks: results, statisticsNote: note)
+ }
+}
+
+enum TableHealthProviderFactory {
+ static func provider(for databaseType: DatabaseType) -> TableHealthProvider? {
+ switch databaseType {
+ case .postgresql:
+ return PostgreSQLTableHealthProvider()
+ case .mysql, .mariadb:
+ return MySQLTableHealthProvider()
+ default:
+ return nil
+ }
+ }
+}
+
+/// A result row read by column name, so a reordered SELECT list cannot shift a value.
+struct TableHealthRow {
+ private let values: [PluginCellValue]
+ private let columns: [String: Int]
+
+ static func rows(of result: QueryResult) -> [TableHealthRow] {
+ var columns: [String: Int] = [:]
+ for (index, name) in result.columns.enumerated() {
+ columns[name.lowercased()] = index
+ }
+ return result.rows.map { TableHealthRow(values: $0, columns: columns) }
+ }
+
+ subscript(_ name: String) -> String {
+ guard let index = columns[name], index < values.count else { return "" }
+ return values[index].asText ?? ""
+ }
+}
diff --git a/apple/TablePro/Models/Query/QueryTabManager.swift b/apple/TablePro/Models/Query/QueryTabManager.swift
index 49b0da031f..b436ea0729 100644
--- a/apple/TablePro/Models/Query/QueryTabManager.swift
+++ b/apple/TablePro/Models/Query/QueryTabManager.swift
@@ -529,6 +529,18 @@ final class QueryTabManager: ObservableObject {
selectedTabId = newTab.id
}
+ func addTableHealthTab() {
+ if let existing = tabs.first(where: { $0.tabType == .tableHealth }) {
+ selectedTabId = existing.id
+ return
+ }
+ var newTab = QueryTab(title: String(localized: "Table Health"), tabType: .tableHealth)
+ newTab.tableContext.isEditable = false
+ newTab.hasUserInteraction = true
+ tabs.append(newTab)
+ selectedTabId = newTab.id
+ }
+
func addObjectSourceTab(objectRef: DatabaseObjectRef) {
if let existing = tabs.first(where: {
$0.tabType == .objectSource && $0.display.objectRef?.namesSameObject(as: objectRef) == true
@@ -833,7 +845,7 @@ extension QueryTabManager {
case .query, .table, .createTable, .erDiagram, .objectSource, .tableListing:
return true
// A paused debug target holds locks, so its tab stays in sight whichever entry the sidebar shows.
- case .serverDashboard, .usersRoles, .insights, .versionHistory, .routineDebug:
+ case .serverDashboard, .usersRoles, .insights, .tableHealth, .versionHistory, .routineDebug:
return false
}
}
diff --git a/apple/TablePro/Models/Query/QueryTabState.swift b/apple/TablePro/Models/Query/QueryTabState.swift
index 731d95864a..db3caf8fba 100644
--- a/apple/TablePro/Models/Query/QueryTabState.swift
+++ b/apple/TablePro/Models/Query/QueryTabState.swift
@@ -16,6 +16,7 @@ enum TabType: Equatable, Codable, Hashable {
case serverDashboard
case usersRoles
case insights
+ case tableHealth
case objectSource
case versionHistory
// Read-only grid of one database's or schema's tables, rebuilt from the catalog on each load, never from saved SQL.
diff --git a/apple/TablePro/Models/TableHealth/TableHealthModels.swift b/apple/TablePro/Models/TableHealth/TableHealthModels.swift
new file mode 100644
index 0000000000..db130ce54c
--- /dev/null
+++ b/apple/TablePro/Models/TableHealth/TableHealthModels.swift
@@ -0,0 +1,71 @@
+//
+// TableHealthModels.swift
+// TablePro
+//
+
+import Foundation
+
+enum TableHealthCheckKind: String, CaseIterable, Identifiable {
+ case bloat
+ case unusedIndex
+ case redundantIndex
+ case missingPrimaryKey
+ case unindexedForeignKey
+
+ var id: String { rawValue }
+
+ var title: String {
+ switch self {
+ case .bloat: return String(localized: "Bloated Tables")
+ case .unusedIndex: return String(localized: "Unused Indexes")
+ case .redundantIndex: return String(localized: "Redundant Indexes")
+ case .missingPrimaryKey: return String(localized: "Tables Without a Primary Key")
+ case .unindexedForeignKey: return String(localized: "Foreign Keys Without an Index")
+ }
+ }
+
+ var systemImage: String {
+ switch self {
+ case .bloat: return "trash"
+ case .unusedIndex: return "list.bullet.indent"
+ case .redundantIndex: return "square.on.square"
+ case .missingPrimaryKey: return "key"
+ case .unindexedForeignKey: return "link"
+ }
+ }
+}
+
+struct TableHealthFinding: Identifiable, Hashable {
+ let kind: TableHealthCheckKind
+ let schema: String
+ let table: String
+ /// The index or constraint the finding is about, nil when it is about the whole table.
+ let objectName: String?
+ let detail: String
+ /// Shown as a preview only: nothing in the report runs it.
+ let fixSQL: String
+
+ var id: String {
+ [kind.rawValue, schema, table, objectName ?? ""].joined(separator: "\u{1F}")
+ }
+}
+
+struct TableHealthCheckResult: Identifiable {
+ static let findingLimit = 500
+
+ let kind: TableHealthCheckKind
+ var findings: [TableHealthFinding]
+ var error: String?
+
+ var id: String { kind.id }
+}
+
+struct TableHealthReport {
+ var checks: [TableHealthCheckResult]
+ /// Index usage counts only since the statistics were last reset, so an index can look unused because they were.
+ var statisticsNote: String?
+
+ var findingCount: Int {
+ checks.reduce(0) { $0 + $1.findings.count }
+ }
+}
diff --git a/apple/TablePro/Models/UI/GridSelectionOwner.swift b/apple/TablePro/Models/UI/GridSelectionOwner.swift
index 9a212ceb91..4364959955 100644
--- a/apple/TablePro/Models/UI/GridSelectionOwner.swift
+++ b/apple/TablePro/Models/UI/GridSelectionOwner.swift
@@ -32,8 +32,8 @@ internal enum GridSelectionOwner: Equatable {
case .table, .query:
// Map selects through the same model: a clicked shape selects its row.
return .dataGrid
- case .createTable, .erDiagram, .serverDashboard, .usersRoles, .insights, .objectSource, .versionHistory,
- .tableListing, .routineDebug:
+ case .createTable, .erDiagram, .serverDashboard, .usersRoles, .insights, .tableHealth, .objectSource,
+ .versionHistory, .tableListing, .routineDebug:
return .none
}
}
diff --git a/apple/TablePro/Models/UI/PendingChangeKind.swift b/apple/TablePro/Models/UI/PendingChangeKind.swift
index 93e2626fc7..74c21d62be 100644
--- a/apple/TablePro/Models/UI/PendingChangeKind.swift
+++ b/apple/TablePro/Models/UI/PendingChangeKind.swift
@@ -36,8 +36,8 @@ internal enum PendingChangeKind: Equatable, Hashable, Sendable {
return hasCreateTablePending ? .createTable : nil
case .usersRoles:
return hasPrincipalChanges ? .principals : nil
- case .query, .table, .erDiagram, .serverDashboard, .insights, .objectSource, .versionHistory, .tableListing,
- .routineDebug:
+ case .query, .table, .erDiagram, .serverDashboard, .insights, .tableHealth, .objectSource, .versionHistory,
+ .tableListing, .routineDebug:
return contentKind(
hasDataChanges: hasDataChanges,
hasStructureChanges: hasStructureChanges,
diff --git a/apple/TablePro/Resources/Localizable.xcstrings b/apple/TablePro/Resources/Localizable.xcstrings
index 4b999ea5d9..a33c0a511c 100644
--- a/apple/TablePro/Resources/Localizable.xcstrings
+++ b/apple/TablePro/Resources/Localizable.xcstrings
@@ -3534,6 +3534,9 @@
"%@ has no public procedure or function to start from." : {
"extractionState" : "manual",
"generatesSymbol" : false
+ },
+ "%@ has no recorded reads." : {
+
},
"%@ holds a document or an array, and this text is not valid JSON." : {
"extractionState" : "manual",
@@ -6933,6 +6936,12 @@
}
}
}
+ },
+ "%1$@ dead rows, %2$@ live. Last vacuumed %3$@." : {
+
+ },
+ "%1$@ dead rows, %2$@ live. Never vacuumed." : {
+
},
"%1$@ does not apply to a %2$@." : {
"localizations" : {
@@ -6981,6 +6990,9 @@
}
}
}
+ },
+ "%1$@ has never been scanned. It takes %2$@." : {
+
},
"%1$@ has no equivalent here, so the values arrive as %2$@." : {
"localizations" : {
@@ -7082,6 +7094,9 @@
},
"%1$@ is a light theme, so %2$@ is used." : {
+ },
+ "%1$@ is covered by %2$@." : {
+
},
"%1$@ is not a level %2$@ accepts. Use one of %3$@." : {
"extractionState" : "manual",
@@ -7244,6 +7259,9 @@
},
"%1$@ of more than %2$@ rows" : {
+ },
+ "%1$@ on (%2$@) has no index, so deletes and updates on the referenced table scan this one." : {
+
},
"%1$@ on %2$@" : {
"localizations" : {
@@ -10483,6 +10501,9 @@
},
"%lld files" : {
+ },
+ "%lld findings" : {
+
},
"%lld in, %lld out" : {
"localizations" : {
@@ -31261,6 +31282,9 @@
},
"Binary values limit must be between 1 and 1,048,576 KB." : {
+ },
+ "Bloated Tables" : {
+
},
"Block" : {
"extractionState" : "stale",
@@ -50740,6 +50764,9 @@
}
}
}
+ },
+ "Copy Fix SQL" : {
+
},
"Copy ID" : {
"localizations" : {
@@ -53517,6 +53544,9 @@
}
}
}
+ },
+ "Could Not Read Table Health" : {
+
},
"Could not read the AppRole secret ID file: %@" : {
@@ -84174,6 +84204,9 @@
}
}
}
+ },
+ "Foreign Keys Without an Index" : {
+
},
"Foreign Table" : {
"localizations" : {
@@ -93524,6 +93557,15 @@
}
}
}
+ },
+ "Index usage counts since the server started at %@." : {
+
+ },
+ "Index usage counts since the statistics were first collected; they have never been reset." : {
+
+ },
+ "Index usage counts since the statistics were last reset at %@." : {
+
},
"Indexed columns in order" : {
"extractionState" : "manual",
@@ -104666,6 +104708,9 @@
}
}
}
+ },
+ "Loading table health…" : {
+
},
"Loading team…" : {
"localizations" : {
@@ -116585,6 +116630,9 @@
}
}
}
+ },
+ "No findings." : {
+
},
"No foreign key links %@ to the tables before it, so it is cross joined." : {
@@ -118096,6 +118144,9 @@
}
}
}
+ },
+ "No primary key. Replace column_name with the columns that identify a row." : {
+
},
"No privileges" : {
"localizations" : {
@@ -126786,6 +126837,9 @@
}
}
}
+ },
+ "Open Fix in Editor" : {
+
},
"Open in Agent Mode" : {
"localizations" : {
@@ -132507,6 +132561,9 @@
}
}
}
+ },
+ "Performance Schema is off, so the server records no index usage and no index can show as unused." : {
+
},
"period" : {
"extractionState" : "manual",
@@ -143788,6 +143845,9 @@
},
"Redshift Endpoint" : {
+ },
+ "Redundant Indexes" : {
+
},
"Ref Columns" : {
"localizations" : {
@@ -159684,6 +159744,9 @@
}
}
}
+ },
+ "Select a finding to preview the SQL that fixes it." : {
+
},
"Select a Library" : {
"localizations" : {
@@ -177746,6 +177809,15 @@
}
}
}
+ },
+ "Table Health" : {
+
+ },
+ "Table health is available for PostgreSQL, MySQL and MariaDB." : {
+
+ },
+ "Table Health Not Available" : {
+
},
"Table Info" : {
"extractionState" : "stale",
@@ -180702,6 +180774,9 @@
}
}
}
+ },
+ "Tables Without a Primary Key" : {
+
},
"Tables, views, and functions owned by this role will be deleted." : {
"localizations" : {
@@ -199130,6 +199205,9 @@
}
}
}
+ },
+ "This SQL is a preview. TablePro never runs it; review it in the editor and run it yourself." : {
+
},
"This statement changed %1$d rows where at most %2$d was expected." : {
"localizations" : {
@@ -206705,6 +206783,9 @@
}
}
}
+ },
+ "Unused Indexes" : {
+
},
"Up to Date" : {
diff --git a/apple/TablePro/Resources/TablePro.sdef b/apple/TablePro/Resources/TablePro.sdef
index 1fde85736e..fcfdc8c001 100644
--- a/apple/TablePro/Resources/TablePro.sdef
+++ b/apple/TablePro/Resources/TablePro.sdef
@@ -30,6 +30,7 @@
+
diff --git a/apple/TablePro/ViewModels/TableHealthViewModel.swift b/apple/TablePro/ViewModels/TableHealthViewModel.swift
new file mode 100644
index 0000000000..06df61088e
--- /dev/null
+++ b/apple/TablePro/ViewModels/TableHealthViewModel.swift
@@ -0,0 +1,94 @@
+//
+// TableHealthViewModel.swift
+// TablePro
+//
+
+import Combine
+import Foundation
+import os
+
+@MainActor
+final class TableHealthViewModel: ObservableObject {
+ typealias Execute = @MainActor (String) async throws -> QueryResult
+ /// Throws when the connection has no live driver, so the report says so once instead of once per check.
+ typealias ExecutorSource = @MainActor () throws -> Execute
+
+ nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "TableHealth")
+
+ let databaseType: DatabaseType
+ let provider: TableHealthProvider?
+
+ @Published private(set) var report: TableHealthReport?
+ @Published private(set) var isRefreshing = false
+ @Published private(set) var lastRefreshDate: Date?
+ @Published private(set) var loadError: String?
+ @Published var selectedFindingId: TableHealthFinding.ID?
+
+ private let executorSource: ExecutorSource
+
+ var isSupported: Bool { provider != nil }
+
+ var selectedFinding: TableHealthFinding? {
+ guard let selectedFindingId, let report else { return nil }
+ for check in report.checks {
+ if let finding = check.findings.first(where: { $0.id == selectedFindingId }) {
+ return finding
+ }
+ }
+ return nil
+ }
+
+ init(
+ databaseType: DatabaseType,
+ provider: TableHealthProvider?,
+ executorSource: @escaping ExecutorSource
+ ) {
+ self.databaseType = databaseType
+ self.provider = provider
+ self.executorSource = executorSource
+ }
+
+ convenience init(connectionId: UUID, databaseType: DatabaseType, services: AppServices = .live) {
+ self.init(
+ databaseType: databaseType,
+ provider: TableHealthProviderFactory.provider(for: databaseType),
+ executorSource: {
+ guard services.databaseManager.driver(for: connectionId) != nil else {
+ throw DatabaseError.connectionFailed(String(localized: "No active connection"))
+ }
+ return { query in
+ guard let driver = services.databaseManager.driver(for: connectionId) else {
+ throw DatabaseError.connectionFailed(String(localized: "No active connection"))
+ }
+ return try await driver.executeRead(query: query)
+ }
+ }
+ )
+ }
+
+ /// A pane switch re-runs `.task`, so only the first appearance loads.
+ func loadIfNeeded() async {
+ guard report == nil, loadError == nil else { return }
+ await refresh()
+ }
+
+ func refresh() async {
+ guard let provider, !isRefreshing else { return }
+ isRefreshing = true
+ defer { isRefreshing = false }
+
+ do {
+ let execute = try executorSource()
+ let fresh = try await provider.fetchReport(execute: execute)
+ report = fresh
+ loadError = nil
+ lastRefreshDate = Date()
+ if selectedFinding == nil { selectedFindingId = nil }
+ } catch is CancellationError {
+ return
+ } catch {
+ Self.logger.warning("Table health refresh failed: \(error.publicLogShape, privacy: .public)")
+ loadError = error.localizedDescription
+ }
+ }
+}
diff --git a/apple/TablePro/Views/Main/Child/MainEditorContentView.swift b/apple/TablePro/Views/Main/Child/MainEditorContentView.swift
index 1fdd1164aa..7ee73cbb71 100644
--- a/apple/TablePro/Views/Main/Child/MainEditorContentView.swift
+++ b/apple/TablePro/Views/Main/Child/MainEditorContentView.swift
@@ -55,6 +55,7 @@ struct MainEditorContentView: View {
@State private var serverDashboardViewModels: [UUID: ServerDashboardViewModel] = [:]
@State private var usersRolesViewModels: [UUID: UsersRolesViewModel] = [:]
@State private var queryInsightsViewModels: [UUID: QueryInsightsViewModel] = [:]
+ @State private var tableHealthViewModels: [UUID: TableHealthViewModel] = [:]
@State private var tableListingModels = TableListingModelStore()
@ObservedObject private var treeService = DatabaseTreeMetadataService.shared
@@ -154,6 +155,7 @@ struct MainEditorContentView: View {
serverDashboardViewModels = serverDashboardViewModels.filter { openTabIds.contains($0.key) }
usersRolesViewModels = usersRolesViewModels.filter { openTabIds.contains($0.key) }
queryInsightsViewModels = queryInsightsViewModels.filter { openTabIds.contains($0.key) }
+ tableHealthViewModels = tableHealthViewModels.filter { openTabIds.contains($0.key) }
tableListingModels.retainTabs(openTabIds)
SchemaProviderRegistry.shared.reclaimUnheldProviders(for: connectionId)
}
@@ -204,6 +206,8 @@ struct MainEditorContentView: View {
usersRolesContent(tab: tab)
case .insights:
queryInsightsContent(tab: tab)
+ case .tableHealth:
+ tableHealthContent(tab: tab)
case .objectSource:
objectSourceContent(tab: tab)
case .versionHistory:
@@ -314,6 +318,27 @@ struct MainEditorContentView: View {
.id(tab.id)
}
+ // MARK: - Table Health Tab Content
+
+ private func tableHealthContent(tab: QueryTab) -> some View {
+ Group {
+ if let vm = tableHealthViewModels[tab.id] {
+ TableHealthView(viewModel: vm, coordinator: coordinator)
+ } else {
+ ProgressView(String(localized: "Loading table health…"))
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .onAppear {
+ guard tableHealthViewModels[tab.id] == nil else { return }
+ tableHealthViewModels[tab.id] = TableHealthViewModel(
+ connectionId: connection.id,
+ databaseType: connection.type
+ )
+ }
+ }
+ }
+ .id(tab.id)
+ }
+
// MARK: - Users & Roles Tab Content
@ViewBuilder
diff --git a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+TableHealth.swift b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+TableHealth.swift
new file mode 100644
index 0000000000..2b4beba778
--- /dev/null
+++ b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+TableHealth.swift
@@ -0,0 +1,44 @@
+import AppKit
+import Foundation
+
+extension MainContentCoordinator {
+ /// Open (or focus) the Table Health tab for this connection, one per connection like the Server Dashboard.
+ func showTableHealth() {
+ if let existing = Self.coordinator(forConnection: connectionId, tabMatching: {
+ $0.tabType == .tableHealth
+ }), let match = existing.tabManager.tabs.first(where: { $0.tabType == .tableHealth }) {
+ existing.selectTabAndFocusWindow(match.id)
+ return
+ }
+
+ if tabManager.tabs.isEmpty {
+ tabManager.addTableHealthTab()
+ return
+ }
+
+ let payload = EditorTabPayload(
+ connectionId: connection.id,
+ tabType: .tableHealth,
+ databaseName: browseDatabaseName
+ )
+ WindowManager.shared.openTab(payload: payload)
+ }
+
+ func openTableHealthFinding(_ finding: TableHealthFinding, schemaNamesDatabase: Bool) {
+ if schemaNamesDatabase {
+ openTableTab(finding.table, database: finding.schema)
+ } else {
+ openTableTab(finding.table, schema: finding.schema)
+ }
+ }
+
+ /// Puts the fix in an editor tab and stops there: the user reads it and runs it themselves.
+ func openTableHealthFix(_ finding: TableHealthFinding, schemaNamesDatabase: Bool) {
+ openQuery(
+ finding.fixSQL,
+ on: connectionId,
+ databaseName: schemaNamesDatabase ? finding.schema : browseDatabaseName,
+ intent: .loadIntoFrontTab
+ )
+ }
+}
diff --git a/apple/TablePro/Views/Main/MainContentCommandActions+DatabaseTools.swift b/apple/TablePro/Views/Main/MainContentCommandActions+DatabaseTools.swift
index 785c076147..cb4c589d2f 100644
--- a/apple/TablePro/Views/Main/MainContentCommandActions+DatabaseTools.swift
+++ b/apple/TablePro/Views/Main/MainContentCommandActions+DatabaseTools.swift
@@ -31,6 +31,10 @@ extension MainContentCommandActions {
coordinator?.showQueryInsights()
}
+ func showTableHealth() {
+ coordinator?.showTableHealth()
+ }
+
var supportsServerDashboard: Bool {
guard let type = coordinator?.connection.type else { return false }
return ServerDashboardQueryProviderFactory.supportsDashboard(for: type)
diff --git a/apple/TablePro/Views/TableHealth/TableHealthView.swift b/apple/TablePro/Views/TableHealth/TableHealthView.swift
new file mode 100644
index 0000000000..3b75306cb4
--- /dev/null
+++ b/apple/TablePro/Views/TableHealth/TableHealthView.swift
@@ -0,0 +1,224 @@
+import SwiftUI
+
+struct TableHealthView: View {
+ @ObservedObject var viewModel: TableHealthViewModel
+ @ObservedObject var coordinator: MainContentCoordinator
+
+ var body: some View {
+ content
+ .safeAreaInset(edge: .top, spacing: 0) {
+ if viewModel.isSupported {
+ toolbar
+ }
+ }
+ .task { await viewModel.loadIfNeeded() }
+ }
+
+ @ViewBuilder
+ private var content: some View {
+ if !viewModel.isSupported {
+ UnavailableStateView(
+ String(localized: "Table Health Not Available"),
+ systemImage: "stethoscope",
+ description: Text("Table health is available for PostgreSQL, MySQL and MariaDB.")
+ )
+ } else if let report = viewModel.report {
+ VSplitView {
+ findingsList(report)
+ .frame(minHeight: 160)
+ detailPane
+ .frame(minHeight: 120, idealHeight: 180)
+ }
+ } else if let error = viewModel.loadError {
+ UnavailableStateView {
+ Label(String(localized: "Could Not Read Table Health"), systemImage: "exclamationmark.triangle")
+ } description: {
+ Text(verbatim: error)
+ } actions: {
+ Button(String(localized: "Try Again")) {
+ Task { await viewModel.refresh() }
+ }
+ }
+ } else {
+ ProgressView()
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+ }
+
+ // MARK: - Toolbar
+
+ private var toolbar: some View {
+ VStack(spacing: 0) {
+ HStack(spacing: 8) {
+ if let report = viewModel.report {
+ Text(String(format: String(localized: "%lld findings"), report.findingCount))
+ .font(.callout)
+ .monospacedDigit()
+ }
+ if let error = viewModel.loadError, viewModel.report != nil {
+ Image(systemName: "exclamationmark.triangle")
+ .foregroundStyle(.secondary)
+ .help(error)
+ .accessibilityLabel(error)
+ }
+
+ Spacer(minLength: 8)
+
+ if viewModel.isRefreshing {
+ ProgressView()
+ .controlSize(.small)
+ .accessibilityLabel(String(localized: "Refreshing"))
+ } else if let date = viewModel.lastRefreshDate {
+ Text(date, style: .time)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .monospacedDigit()
+ .accessibilityLabel(String(localized: "Last updated"))
+ }
+
+ Button {
+ Task { await viewModel.refresh() }
+ } label: {
+ Image(systemName: "arrow.clockwise")
+ }
+ .buttonStyle(.borderless)
+ .help(String(localized: "Refresh Now"))
+ .accessibilityLabel(String(localized: "Refresh Now"))
+ .disabled(viewModel.isRefreshing)
+ }
+ .padding(.horizontal, 10)
+ .padding(.vertical, 6)
+
+ Divider()
+ }
+ .paneBarBackground()
+ }
+
+ // MARK: - Findings
+
+ private func findingsList(_ report: TableHealthReport) -> some View {
+ List(selection: $viewModel.selectedFindingId) {
+ if let note = report.statisticsNote {
+ Section {
+ Label(note, systemImage: "info.circle")
+ .font(.callout)
+ .foregroundStyle(.secondary)
+ }
+ }
+ ForEach(report.checks) { check in
+ Section {
+ checkRows(check)
+ } header: {
+ HStack {
+ Label(check.kind.title, systemImage: check.kind.systemImage)
+ Spacer()
+ if check.error == nil {
+ Text(verbatim: "\(check.findings.count)")
+ .monospacedDigit()
+ }
+ }
+ }
+ }
+ }
+ .contextMenu(forSelectionType: TableHealthFinding.ID.self) { ids in
+ if let finding = finding(in: ids) {
+ Button(String(localized: "Open Table")) { openTable(finding) }
+ Button(String(localized: "Open Fix in Editor")) { openFix(finding) }
+ Button(String(localized: "Copy Fix SQL")) { copyFix(finding) }
+ }
+ } primaryAction: { ids in
+ if let finding = finding(in: ids) { openTable(finding) }
+ }
+ }
+
+ @ViewBuilder
+ private func checkRows(_ check: TableHealthCheckResult) -> some View {
+ if let error = check.error {
+ Label {
+ Text(verbatim: error)
+ } icon: {
+ Image(systemName: "exclamationmark.triangle")
+ }
+ .font(.callout)
+ .foregroundStyle(.secondary)
+ } else if check.findings.isEmpty {
+ Text("No findings.")
+ .font(.callout)
+ .foregroundStyle(.secondary)
+ } else {
+ ForEach(check.findings) { finding in
+ VStack(alignment: .leading, spacing: 2) {
+ Text(verbatim: Self.qualifiedName(finding))
+ Text(verbatim: finding.detail)
+ .font(.callout)
+ .foregroundStyle(.secondary)
+ .lineLimit(2)
+ }
+ .padding(.vertical, 2)
+ .tag(finding.id)
+ }
+ }
+ }
+
+ // MARK: - Detail
+
+ @ViewBuilder
+ private var detailPane: some View {
+ if let finding = viewModel.selectedFinding {
+ VStack(alignment: .leading, spacing: 8) {
+ HStack(spacing: 8) {
+ Text(verbatim: Self.qualifiedName(finding))
+ .font(.headline)
+ .lineLimit(1)
+ .truncationMode(.middle)
+ Spacer(minLength: 8)
+ Button(String(localized: "Open Table")) { openTable(finding) }
+ Button(String(localized: "Copy")) { copyFix(finding) }
+ Button(String(localized: "Open in Editor")) { openFix(finding) }
+ }
+ ScrollView {
+ Text(verbatim: finding.fixSQL)
+ .font(.system(.callout, design: .monospaced))
+ .textSelection(.enabled)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ Text("This SQL is a preview. TablePro never runs it; review it in the editor and run it yourself.")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ .padding(12)
+ } else {
+ Text("Select a finding to preview the SQL that fixes it.")
+ .font(.callout)
+ .foregroundStyle(.secondary)
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+ }
+
+ // MARK: - Actions
+
+ private var schemaNamesDatabase: Bool {
+ viewModel.provider?.schemaNamesDatabase ?? false
+ }
+
+ private func finding(in ids: Set) -> TableHealthFinding? {
+ guard ids.count == 1, let id = ids.first, let report = viewModel.report else { return nil }
+ return report.checks.lazy.flatMap(\.findings).first { $0.id == id }
+ }
+
+ private func openTable(_ finding: TableHealthFinding) {
+ coordinator.openTableHealthFinding(finding, schemaNamesDatabase: schemaNamesDatabase)
+ }
+
+ private func openFix(_ finding: TableHealthFinding) {
+ coordinator.openTableHealthFix(finding, schemaNamesDatabase: schemaNamesDatabase)
+ }
+
+ private func copyFix(_ finding: TableHealthFinding) {
+ ClipboardService.shared.writeText(finding.fixSQL)
+ }
+
+ static func qualifiedName(_ finding: TableHealthFinding) -> String {
+ finding.schema.isEmpty ? finding.table : "\(finding.schema).\(finding.table)"
+ }
+}
diff --git a/apple/TableProTests/Core/RoutineDebugging/RoutineDebugToolbarTests.swift b/apple/TableProTests/Core/RoutineDebugging/RoutineDebugToolbarTests.swift
index 07a89bd7c0..a442785dbe 100644
--- a/apple/TableProTests/Core/RoutineDebugging/RoutineDebugToolbarTests.swift
+++ b/apple/TableProTests/Core/RoutineDebugging/RoutineDebugToolbarTests.swift
@@ -39,8 +39,8 @@ struct RoutineDebugToolbarTests {
}
@Test("Every other tab kind and Agent mode take the debug pair out of the titlebar", arguments: [
- TabType.query, .table, .createTable, .erDiagram, .serverDashboard, .usersRoles, .insights, .objectSource,
- .versionHistory, .tableListing,
+ TabType.query, .table, .createTable, .erDiagram, .serverDashboard, .usersRoles, .insights, .tableHealth,
+ .objectSource, .versionHistory, .tableListing,
])
func otherKindsHideThePair(tabKind: TabType) {
#expect(Self.hidden(tabKind: tabKind).isSuperset(of: Self.pair))
diff --git a/apple/TableProTests/Core/Scripting/ScriptingDictionaryTests.swift b/apple/TableProTests/Core/Scripting/ScriptingDictionaryTests.swift
index bcbacfbea0..f8b81c018b 100644
--- a/apple/TableProTests/Core/Scripting/ScriptingDictionaryTests.swift
+++ b/apple/TableProTests/Core/Scripting/ScriptingDictionaryTests.swift
@@ -351,7 +351,7 @@ struct ScriptingDictionaryTests {
}
for kind in [
TabType.query, .table, .createTable, .erDiagram,
- .serverDashboard, .usersRoles, .insights, .objectSource, .versionHistory, .tableListing
+ .serverDashboard, .usersRoles, .insights, .tableHealth, .objectSource, .versionHistory, .tableListing
] {
let code = try #require(ScriptEnumerations.code(for: kind), "no code for \(kind)")
#expect(declared.contains(code), "no enumerator for \(kind)")
diff --git a/apple/TableProTests/Core/Services/EditorTabConnectionMovePolicyTests.swift b/apple/TableProTests/Core/Services/EditorTabConnectionMovePolicyTests.swift
index 8417537ccf..401701638c 100644
--- a/apple/TableProTests/Core/Services/EditorTabConnectionMovePolicyTests.swift
+++ b/apple/TableProTests/Core/Services/EditorTabConnectionMovePolicyTests.swift
@@ -17,7 +17,7 @@ struct EditorTabConnectionMovePolicyTests {
@Test("Every other tab type names an object of its own connection and stays")
func nonQueryTabsStay() {
for tabType in [TabType.table, .createTable, .erDiagram, .serverDashboard, .usersRoles, .insights,
- .objectSource, .versionHistory, .tableListing, .routineDebug] {
+ .tableHealth, .objectSource, .versionHistory, .tableListing, .routineDebug] {
#expect(!EditorTabConnectionMovePolicy.canMove(tabType: tabType, isBusy: false, hasPendingGridEdits: false))
}
}
diff --git a/apple/TableProTests/Core/TableHealth/MySQLTableHealthProviderTests.swift b/apple/TableProTests/Core/TableHealth/MySQLTableHealthProviderTests.swift
new file mode 100644
index 0000000000..4e361cd0ff
--- /dev/null
+++ b/apple/TableProTests/Core/TableHealth/MySQLTableHealthProviderTests.swift
@@ -0,0 +1,159 @@
+//
+// MySQLTableHealthProviderTests.swift
+// TableProTests
+//
+
+import Foundation
+@testable import TablePro
+import TableProPluginKit
+import Testing
+
+struct MySQLTableHealthProviderTests {
+ private let provider = MySQLTableHealthProvider()
+
+ private typealias IndexRow = (index: String, nonUnique: String, type: String, column: String?, subPart: String?)
+
+ private func redundant(in rows: [IndexRow], table: String = "orders") -> [String: String] {
+ let result = TableHealthFixture.result(
+ columns: ["schema_name", "table_name", "index_name", "non_unique", "index_type", "column_name", "sub_part"],
+ rows: rows.map { row -> [String?] in ["shop", table, row.index, row.nonUnique, row.type, row.column, row.subPart] }
+ )
+ var map: [String: String] = [:]
+ for finding in provider.findings(for: .redundantIndex, from: result) {
+ map[finding.objectName ?? ""] = finding.detail
+ }
+ return map
+ }
+
+ @Test("An index whose columns lead another index is redundant to it")
+ func prefixIsRedundant() {
+ let found = redundant(in: [
+ ("PRIMARY", "0", "BTREE", "id", nil),
+ ("idx_a", "1", "BTREE", "a", nil),
+ ("idx_ab", "1", "BTREE", "a", nil),
+ ("idx_ab", "1", "BTREE", "b", nil),
+ ("uniq_b", "0", "BTREE", "b", nil)
+ ])
+ #expect(Array(found.keys) == ["idx_a"])
+ #expect(found["idx_a"]?.contains("idx_ab") == true)
+ }
+
+ @Test("A shorter unique index enforces more than the longer one, so it stays")
+ func shorterUniqueStays() {
+ let found = redundant(in: [
+ ("uniq_a", "0", "BTREE", "a", nil),
+ ("idx_ab", "1", "BTREE", "a", nil),
+ ("idx_ab", "1", "BTREE", "b", nil)
+ ])
+ #expect(found.isEmpty)
+ }
+
+ @Test("Of two equal plain indexes, the one later by name is redundant")
+ func equalPlainIndexes() {
+ let found = redundant(in: [
+ ("idx_x", "1", "BTREE", "a", nil),
+ ("idx_y", "1", "BTREE", "a", nil)
+ ])
+ #expect(Array(found.keys) == ["idx_y"])
+ }
+
+ @Test("A plain index equal to a unique one is redundant to it")
+ func plainEqualToUnique() {
+ let found = redundant(in: [
+ ("a_plain", "1", "BTREE", "a", nil),
+ ("z_unique", "0", "BTREE", "a", nil)
+ ])
+ #expect(Array(found.keys) == ["a_plain"])
+ }
+
+ @Test("A unique index equal to the primary key is redundant, and the primary key never is")
+ func uniqueEqualToPrimary() {
+ let found = redundant(in: [
+ ("PRIMARY", "0", "BTREE", "id", nil),
+ ("id_unique", "0", "BTREE", "id", nil)
+ ])
+ #expect(Array(found.keys) == ["id_unique"])
+ }
+
+ @Test("A column prefix length, an expression or another index type keeps an index apart")
+ func shapesThatDoNotCompare() {
+ let found = redundant(in: [
+ ("idx_a10", "1", "BTREE", "a", "10"),
+ ("idx_ab", "1", "BTREE", "a", nil),
+ ("idx_ab", "1", "BTREE", "b", nil),
+ ("idx_expr", "1", "BTREE", nil, nil),
+ ("idx_expr", "1", "BTREE", "b", nil),
+ ("ft_a", "1", "FULLTEXT", "a", nil)
+ ])
+ #expect(found.isEmpty)
+ }
+
+ @Test("Indexes compare only within their own table")
+ func tablesStayApart() {
+ let result = TableHealthFixture.result(
+ columns: ["schema_name", "table_name", "index_name", "non_unique", "index_type", "column_name", "sub_part"],
+ rows: [
+ ["shop", "a", "idx_x", "1", "BTREE", "c", nil],
+ ["shop", "b", "idx_xy", "1", "BTREE", "c", nil],
+ ["shop", "b", "idx_xy", "1", "BTREE", "d", nil]
+ ]
+ )
+ #expect(provider.findings(for: .redundantIndex, from: result).isEmpty)
+ }
+
+ @Test("A redundant index previews a drop with backtick quoting")
+ func redundantFixQuotes() throws {
+ let result = TableHealthFixture.result(
+ columns: ["schema_name", "table_name", "index_name", "non_unique", "index_type", "column_name", "sub_part"],
+ rows: [
+ ["shop", "odd`name", "idx_a", "1", "BTREE", "a", nil],
+ ["shop", "odd`name", "idx_ab", "1", "BTREE", "a", nil],
+ ["shop", "odd`name", "idx_ab", "1", "BTREE", "b", nil]
+ ]
+ )
+ let finding = try #require(provider.findings(for: .redundantIndex, from: result).first)
+ #expect(finding.fixSQL == "ALTER TABLE `shop`.`odd``name` DROP INDEX `idx_a`;")
+ }
+
+ @Test("An unused index and a table without a primary key preview their fixes")
+ func unusedAndMissingKeyFixes() throws {
+ let unused = TableHealthFixture.result(
+ columns: ["schema_name", "table_name", "index_name"],
+ rows: [["shop", "orders", "orders_note_idx"]]
+ )
+ let noKey = TableHealthFixture.result(columns: ["schema_name", "table_name"], rows: [["shop", "logs"]])
+
+ let unusedFinding = try #require(provider.findings(for: .unusedIndex, from: unused).first)
+ let keyFinding = try #require(provider.findings(for: .missingPrimaryKey, from: noKey).first)
+
+ #expect(unusedFinding.fixSQL == "ALTER TABLE `shop`.`orders` DROP INDEX `orders_note_idx`;")
+ #expect(keyFinding.fixSQL == "ALTER TABLE `shop`.`logs` ADD PRIMARY KEY (column_name);")
+ #expect(provider.schemaNamesDatabase)
+ }
+
+ @Test("With Performance Schema off, the note says no index usage is recorded")
+ func performanceSchemaOff() {
+ let off = TableHealthFixture.result(columns: ["performance_schema"], rows: [["0"]])
+ let uptime = TableHealthFixture.result(columns: ["Variable_name", "Value"], rows: [["Uptime", "3600"]])
+ #expect(provider.statisticsNote(from: [off, uptime], now: Date())?.contains("Performance Schema is off") == true)
+ }
+
+ @Test("With Performance Schema on, the note names the server start")
+ func serverStartNote() {
+ let on = TableHealthFixture.result(columns: ["performance_schema"], rows: [["1"]])
+ let uptime = TableHealthFixture.result(columns: ["Variable_name", "Value"], rows: [["Uptime", "3600"]])
+ let now = Date(timeIntervalSince1970: 1_800_000_000)
+ let start = Date(timeIntervalSince1970: 1_800_000_000 - 3600).formatted(date: .abbreviated, time: .shortened)
+
+ #expect(provider.statisticsNote(from: [on, uptime], now: now)?.contains(start) == true)
+ }
+
+ @Test("Every query the report runs only reads")
+ func queriesOnlyRead() {
+ for check in provider.checks {
+ let query = provider.query(for: check).trimmingCharacters(in: .whitespacesAndNewlines)
+ #expect(query.uppercased().hasPrefix("SELECT"), "\(check) does not start with SELECT")
+ }
+ #expect(MySQLTableHealthProvider.uptimeQuery.hasPrefix("SHOW "))
+ }
+}
diff --git a/apple/TableProTests/Core/TableHealth/PostgreSQLTableHealthProviderTests.swift b/apple/TableProTests/Core/TableHealth/PostgreSQLTableHealthProviderTests.swift
new file mode 100644
index 0000000000..9f7a11446c
--- /dev/null
+++ b/apple/TableProTests/Core/TableHealth/PostgreSQLTableHealthProviderTests.swift
@@ -0,0 +1,145 @@
+//
+// PostgreSQLTableHealthProviderTests.swift
+// TableProTests
+//
+
+import Foundation
+@testable import TablePro
+import TableProPluginKit
+import Testing
+
+struct PostgreSQLTableHealthProviderTests {
+ private struct QueryFailure: LocalizedError {
+ var errorDescription: String? { "permission denied for pg_stat_user_tables" }
+ }
+
+ private let provider = PostgreSQLTableHealthProvider()
+
+ @Test("The factory gives PostgreSQL, MySQL and MariaDB a provider and no other engine")
+ func factoryCoversThreeEngines() {
+ #expect(TableHealthProviderFactory.provider(for: .postgresql) is PostgreSQLTableHealthProvider)
+ #expect(TableHealthProviderFactory.provider(for: .mysql) is MySQLTableHealthProvider)
+ #expect(TableHealthProviderFactory.provider(for: .mariadb) is MySQLTableHealthProvider)
+ #expect(TableHealthProviderFactory.provider(for: .sqlite) == nil)
+ #expect(TableHealthProviderFactory.provider(for: .mssql) == nil)
+ }
+
+ @Test("A bloated table names its dead and live rows and previews a vacuum")
+ func bloatFinding() throws {
+ let result = TableHealthFixture.result(
+ columns: ["schema_name", "table_name", "live_rows", "dead_rows", "last_vacuum"],
+ rows: [["public", "orders", "2000", "3000", "2026-10-31 19:48:20+00"]]
+ )
+ let finding = try #require(provider.findings(for: .bloat, from: result).first)
+
+ #expect(finding.kind == .bloat)
+ #expect(finding.schema == "public")
+ #expect(finding.table == "orders")
+ #expect(finding.objectName == nil)
+ #expect(finding.detail.contains("2026-10-31 19:48:20+00"))
+ #expect(finding.fixSQL == #"VACUUM (ANALYZE) "public"."orders";"#)
+ }
+
+ @Test("A table never vacuumed says so")
+ func neverVacuumed() throws {
+ let result = TableHealthFixture.result(
+ columns: ["schema_name", "table_name", "live_rows", "dead_rows", "last_vacuum"],
+ rows: [["public", "orders", "2000", "3000", nil]]
+ )
+ let finding = try #require(provider.findings(for: .bloat, from: result).first)
+ #expect(finding.detail.contains("Never vacuumed"))
+ }
+
+ @Test("An unused index previews a concurrent drop")
+ func unusedIndexFinding() throws {
+ let result = TableHealthFixture.result(
+ columns: ["schema_name", "table_name", "index_name", "index_bytes"],
+ rows: [["sales", "orders", "orders_note_idx", "8192"]]
+ )
+ let finding = try #require(provider.findings(for: .unusedIndex, from: result).first)
+
+ #expect(finding.table == "orders")
+ #expect(finding.objectName == "orders_note_idx")
+ #expect(finding.fixSQL == #"DROP INDEX CONCURRENTLY "sales"."orders_note_idx";"#)
+ }
+
+ @Test("A table without a primary key quotes its name in the preview")
+ func missingPrimaryKeyQuotesName() throws {
+ let result = TableHealthFixture.result(
+ columns: ["schema_name", "table_name"],
+ rows: [["public", #"Odd "Name""#]]
+ )
+ let finding = try #require(provider.findings(for: .missingPrimaryKey, from: result).first)
+ #expect(finding.fixSQL == #"ALTER TABLE "public"."Odd ""Name""" ADD PRIMARY KEY (column_name);"#)
+ }
+
+ @Test("A foreign key's column rows fold into one finding, in key order")
+ func foreignKeyColumnsGroup() throws {
+ let result = TableHealthFixture.result(
+ columns: ["schema_name", "table_name", "constraint_name", "column_name"],
+ rows: [
+ ["public", "line", "line_order_fk", "order_id"],
+ ["public", "line", "line_pair_fk", "y"],
+ ["public", "line", "line_pair_fk", "x"],
+ ["audit", "line", "line_order_fk", "order_id"]
+ ]
+ )
+ let findings = provider.findings(for: .unindexedForeignKey, from: result)
+
+ #expect(findings.map(\.objectName) == ["line_order_fk", "line_pair_fk", "line_order_fk"])
+ #expect(findings.map(\.schema) == ["public", "public", "audit"])
+ #expect(findings[1].fixSQL == #"CREATE INDEX CONCURRENTLY ON "public"."line" ("y", "x");"#)
+ #expect(findings[0].fixSQL == #"CREATE INDEX CONCURRENTLY ON "public"."line" ("order_id");"#)
+ }
+
+ @Test("Findings of one kind have distinct ids, so the list can select each")
+ func findingIdsAreDistinct() {
+ let result = TableHealthFixture.result(
+ columns: ["schema_name", "table_name", "index_name", "index_bytes"],
+ rows: [["a", "t", "i1", "1"], ["a", "t", "i2", "1"], ["b", "t", "i1", "1"]]
+ )
+ let ids = provider.findings(for: .unusedIndex, from: result).map(\.id)
+ #expect(Set(ids).count == 3)
+ }
+
+ @Test("The note names when statistics were reset, or that they never were")
+ func statisticsNote() {
+ let reset = TableHealthFixture.result(columns: ["stats_reset"], rows: [["2026-10-31 19:48:20+00"]])
+ let never = TableHealthFixture.result(columns: ["stats_reset"], rows: [[nil]])
+
+ #expect(provider.statisticsNote(from: [reset], now: Date())?.contains("2026-10-31 19:48:20+00") == true)
+ #expect(provider.statisticsNote(from: [never], now: Date())?.contains("never been reset") == true)
+ #expect(provider.statisticsNote(from: [], now: Date()) == nil)
+ }
+
+ @Test("A failing check reports its error and leaves the other checks standing")
+ func failingCheckIsolated() async throws {
+ let report = try await provider.fetchReport { query in
+ if query == PostgreSQLTableHealthProvider.bloatQuery { throw QueryFailure() }
+ if query == PostgreSQLTableHealthProvider.missingPrimaryKeyQuery {
+ return TableHealthFixture.result(columns: ["schema_name", "table_name"], rows: [["public", "logs"]])
+ }
+ return TableHealthFixture.result(columns: [], rows: [])
+ }
+
+ #expect(report.checks.map(\.kind) == [.bloat, .unusedIndex, .missingPrimaryKey, .unindexedForeignKey])
+ #expect(report.checks[0].error == "permission denied for pg_stat_user_tables")
+ #expect(report.checks[2].findings.map(\.table) == ["logs"])
+ #expect(report.findingCount == 1)
+ }
+
+ @Test("A cancelled refresh stops instead of reporting every check as failed")
+ func cancellationPropagates() async {
+ await #expect(throws: CancellationError.self) {
+ _ = try await provider.fetchReport { _ in throw CancellationError() }
+ }
+ }
+
+ @Test("Every query the report runs only reads")
+ func queriesOnlyRead() {
+ for check in provider.checks {
+ let query = provider.query(for: check).trimmingCharacters(in: .whitespacesAndNewlines)
+ #expect(query.uppercased().hasPrefix("SELECT"), "\(check) does not start with SELECT")
+ }
+ }
+}
diff --git a/apple/TableProTests/Core/TableHealth/TableHealthTestSupport.swift b/apple/TableProTests/Core/TableHealth/TableHealthTestSupport.swift
new file mode 100644
index 0000000000..6b237618b3
--- /dev/null
+++ b/apple/TableProTests/Core/TableHealth/TableHealthTestSupport.swift
@@ -0,0 +1,21 @@
+//
+// TableHealthTestSupport.swift
+// TableProTests
+//
+
+import Foundation
+@testable import TablePro
+import TableProPluginKit
+
+enum TableHealthFixture {
+ static func result(columns: [String], rows: [[String?]]) -> QueryResult {
+ QueryResult(
+ columns: columns,
+ columnTypes: [],
+ rows: rows.map { row in row.map { $0.map { PluginCellValue.text($0) } ?? .null } },
+ rowsAffected: 0,
+ executionTime: 0,
+ error: nil
+ )
+ }
+}
diff --git a/apple/TableProTests/ViewModels/TableHealthViewModelTests.swift b/apple/TableProTests/ViewModels/TableHealthViewModelTests.swift
new file mode 100644
index 0000000000..122536f5cc
--- /dev/null
+++ b/apple/TableProTests/ViewModels/TableHealthViewModelTests.swift
@@ -0,0 +1,121 @@
+//
+// TableHealthViewModelTests.swift
+// TableProTests
+//
+
+import Foundation
+@testable import TablePro
+import TableProPluginKit
+import Testing
+
+@MainActor
+struct TableHealthViewModelTests {
+ private struct Offline: LocalizedError {
+ var errorDescription: String? { "No active connection" }
+ }
+
+ /// Answers the missing primary key check with the given tables and every other check with nothing.
+ @MainActor
+ private final class FakeServer {
+ var tablesWithoutKey: [String] = ["logs"]
+ var isOnline = true
+ private(set) var queryCount = 0
+
+ func executorSource() throws -> TableHealthViewModel.Execute {
+ guard isOnline else { throw Offline() }
+ return { [self] query in
+ queryCount += 1
+ guard query == PostgreSQLTableHealthProvider.missingPrimaryKeyQuery else {
+ return TableHealthFixture.result(columns: [], rows: [])
+ }
+ return TableHealthFixture.result(
+ columns: ["schema_name", "table_name"],
+ rows: tablesWithoutKey.map { table -> [String?] in ["public", table] }
+ )
+ }
+ }
+ }
+
+ private func makeViewModel(_ server: FakeServer, databaseType: DatabaseType = .postgresql) -> TableHealthViewModel {
+ TableHealthViewModel(
+ databaseType: databaseType,
+ provider: TableHealthProviderFactory.provider(for: databaseType),
+ executorSource: { try server.executorSource() }
+ )
+ }
+
+ @Test("An engine without a provider is not supported and runs nothing")
+ func unsupportedEngine() async {
+ let server = FakeServer()
+ let viewModel = makeViewModel(server, databaseType: .sqlite)
+
+ await viewModel.refresh()
+
+ #expect(!viewModel.isSupported)
+ #expect(viewModel.report == nil)
+ #expect(server.queryCount == 0)
+ }
+
+ @Test("The first appearance loads and a second appearance does not reload")
+ func loadsOnce() async {
+ let server = FakeServer()
+ let viewModel = makeViewModel(server)
+
+ await viewModel.loadIfNeeded()
+ let afterFirst = server.queryCount
+ await viewModel.loadIfNeeded()
+
+ #expect(viewModel.report?.findingCount == 1)
+ #expect(afterFirst > 0)
+ #expect(server.queryCount == afterFirst)
+ }
+
+ @Test("A lost connection says so once, and a later refresh clears it")
+ func offlineThenOnline() async {
+ let server = FakeServer()
+ server.isOnline = false
+ let viewModel = makeViewModel(server)
+
+ await viewModel.refresh()
+ #expect(viewModel.loadError == "No active connection")
+ #expect(viewModel.report == nil)
+
+ server.isOnline = true
+ await viewModel.refresh()
+ #expect(viewModel.loadError == nil)
+ #expect(viewModel.report?.findingCount == 1)
+ }
+
+ @Test("A failed refresh keeps the report it already had")
+ func failedRefreshKeepsReport() async {
+ let server = FakeServer()
+ let viewModel = makeViewModel(server)
+ await viewModel.refresh()
+
+ server.isOnline = false
+ await viewModel.refresh()
+
+ #expect(viewModel.report?.findingCount == 1)
+ #expect(viewModel.loadError != nil)
+ }
+
+ @Test("A refresh drops the selection when its finding is gone and keeps it otherwise")
+ func selectionFollowsFindings() async throws {
+ let server = FakeServer()
+ server.tablesWithoutKey = ["logs", "events"]
+ let viewModel = makeViewModel(server)
+ await viewModel.refresh()
+
+ let report = try #require(viewModel.report)
+ let events = try #require(report.checks.flatMap { $0.findings }.first { $0.table == "events" })
+ viewModel.selectedFindingId = events.id
+
+ await viewModel.refresh()
+ #expect(viewModel.selectedFinding?.table == "events")
+
+ server.tablesWithoutKey = ["logs"]
+ await viewModel.refresh()
+ #expect(viewModel.selectedFindingId == nil)
+ #expect(viewModel.selectedFinding == nil)
+ }
+}
diff --git a/apple/scripts/probes/check-table-health-catalogs.sh b/apple/scripts/probes/check-table-health-catalogs.sh
new file mode 100755
index 0000000000..c83748395e
--- /dev/null
+++ b/apple/scripts/probes/check-table-health-catalogs.sh
@@ -0,0 +1,234 @@
+#!/usr/bin/env bash
+#
+# Check the Table Health queries against real PostgreSQL, MySQL and MariaDB servers.
+#
+# The report reads pg_stat_user_tables, pg_stat_user_indexes and the catalogs on PostgreSQL, and the
+# performance_schema and information_schema on MySQL and MariaDB. Each query is read out of the provider
+# source, so the probe and the app cannot drift. The probe builds a database with one table per
+# finding and one per near miss (a unique index, a foreign key an index covers in another order, a
+# partial index), runs every query, checks which objects come back, then runs the fix the report
+# would preview and checks the finding goes away.
+#
+# Usage:
+# scripts/probes/check-table-health-catalogs.sh postgres
+# scripts/probes/check-table-health-catalogs.sh mysql
+#
+# Passwords come from PGPASSWORD and MYSQL_PWD. Needs psql or mysql on PATH, and a user that may
+# create and drop a database. Exits non-zero on a disagreement, 3 when it cannot run.
+
+set -uo pipefail
+
+ENGINE="${1:-}"
+HOST="${2:-127.0.0.1}"
+PORT="${3:-}"
+USER_NAME="${4:-}"
+SOURCES="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/TablePro/Core/TableHealth"
+DATABASE="tablepro_health_check"
+FAILURES=0
+
+# Prints the body of `static let = """ ... """` from a provider file.
+swift_literal() {
+ awk -v name="$2" '
+ $0 ~ "static let " name " = \"\"\"$" { inside = 1; next }
+ inside && $0 ~ /^[[:space:]]*"""[[:space:]]*$/ { exit }
+ inside { print }
+ ' "$1"
+}
+
+swift_line_literal() {
+ sed -n "s/^[[:space:]]*static let $2 = \"\\(.*\\)\"\$/\\1/p" "$1"
+}
+
+expect() {
+ local label="$1" output="$2" needle="$3" wanted="$4"
+ if grep -qF -- "$needle" <<< "$output"; then found=yes; else found=no; fi
+ if [ "$found" = "$wanted" ]; then
+ echo "ok $label"
+ else
+ echo "FAIL $label: expected '$needle' present=$wanted" >&2
+ FAILURES=$((FAILURES + 1))
+ fi
+}
+
+query_of() {
+ local text
+ text="$(swift_literal "$1" "$2")"
+ [ -n "$text" ] || {
+ echo "FAIL: $2 not found in $1" >&2
+ exit 1
+ }
+ printf '%s' "$text"
+}
+
+check_postgres() {
+ local source="$SOURCES/PostgreSQLTableHealthProvider.swift"
+ command -v psql > /dev/null || { echo "psql not found" >&2; exit 3; }
+ export PGOPTIONS="-c client_min_messages=warning"
+ pg() { psql -X -q -A -t -F '|' -h "$HOST" -p "$PORT" -U "$USER_NAME" -v ON_ERROR_STOP=1 "$@"; }
+ pg -d postgres -c "SELECT 1" > /dev/null 2>&1 || { echo "no PostgreSQL at $HOST:$PORT" >&2; exit 3; }
+
+ echo "PostgreSQL $(pg -d postgres -c 'SHOW server_version') at $HOST:$PORT"
+ pg -d postgres -c "DROP DATABASE IF EXISTS $DATABASE" > /dev/null
+ pg -d postgres -c "CREATE DATABASE $DATABASE" > /dev/null
+ trap 'psql -X -q -h "$HOST" -p "$PORT" -U "$USER_NAME" -d postgres -c "DROP DATABASE IF EXISTS '"$DATABASE"'" > /dev/null 2>&1' EXIT
+
+ pg -d "$DATABASE" > /dev/null <<'SQL'
+CREATE TABLE parent (id int PRIMARY KEY, code int UNIQUE);
+CREATE TABLE child_unindexed (id int PRIMARY KEY, parent_id int REFERENCES parent (id));
+CREATE TABLE child_indexed (id int PRIMARY KEY, parent_id int REFERENCES parent (id));
+CREATE INDEX ON child_indexed (parent_id);
+CREATE TABLE pair (x int, y int, PRIMARY KEY (x, y));
+CREATE TABLE child_pair (id int PRIMARY KEY, x int, y int, CONSTRAINT child_pair_fk FOREIGN KEY (x, y) REFERENCES pair (x, y));
+CREATE INDEX ON child_pair (y, x);
+CREATE TABLE child_pair_unindexed (id int PRIMARY KEY, x int, y int, CONSTRAINT child_pair_unindexed_fk FOREIGN KEY (y, x) REFERENCES pair (y, x));
+CREATE TABLE child_partial (id int PRIMARY KEY, parent_id int REFERENCES parent (id));
+CREATE INDEX ON child_partial (parent_id) WHERE parent_id > 0;
+CREATE TABLE child_second (id int PRIMARY KEY, other int, parent_id int REFERENCES parent (id));
+CREATE INDEX ON child_second (other, parent_id);
+CREATE TABLE no_pk (v int);
+CREATE INDEX no_pk_unused_idx ON no_pk (v);
+CREATE UNIQUE INDEX no_pk_unique_idx ON no_pk ((v + 1));
+CREATE TABLE "Odd ""Name""" (v int);
+CREATE TABLE bloated (id int PRIMARY KEY, v int) WITH (autovacuum_enabled = false);
+INSERT INTO bloated SELECT g, g FROM generate_series(1, 5000) g;
+DELETE FROM bloated WHERE id <= 3000;
+CREATE TABLE healthy (id int PRIMARY KEY) WITH (autovacuum_enabled = false);
+INSERT INTO healthy SELECT g FROM generate_series(1, 5000) g;
+DELETE FROM healthy WHERE id <= 10;
+SQL
+
+ local bloat="" attempt
+ for attempt in 1 2 3 4 5 6 7 8 9 10; do
+ bloat="$(pg -d "$DATABASE" -c "$(query_of "$source" bloatQuery)")"
+ grep -qF "bloated" <<< "$bloat" && break
+ sleep 1
+ done
+ expect "bloat lists a table with 60% dead rows" "$bloat" "public|bloated|2000|3000|" yes
+ expect "bloat skips a table with few dead rows" "$bloat" "|healthy|" no
+
+ local unused
+ unused="$(pg -d "$DATABASE" -c "$(query_of "$source" unusedIndexQuery)")"
+ expect "unused index lists a plain index" "$unused" "|no_pk_unused_idx|" yes
+ expect "unused index skips a unique index" "$unused" "no_pk_unique_idx" no
+ expect "unused index skips a primary key" "$unused" "parent_pkey" no
+ expect "unused index skips a unique constraint" "$unused" "parent_code_key" no
+
+ local nopk
+ nopk="$(pg -d "$DATABASE" -c "$(query_of "$source" missingPrimaryKeyQuery)")"
+ expect "missing primary key lists a heap" "$nopk" "public|no_pk" yes
+ expect "missing primary key lists a quoted name" "$nopk" 'public|Odd "Name"' yes
+ expect "missing primary key skips a keyed table" "$nopk" "|parent" no
+ expect "missing primary key skips the system catalogs" "$nopk" "pg_catalog" no
+
+ local fk
+ fk="$(pg -d "$DATABASE" -c "$(query_of "$source" unindexedForeignKeyQuery)")"
+ expect "foreign key without an index" "$fk" "child_unindexed_parent_id_fkey|parent_id" yes
+ expect "foreign key behind a partial index" "$fk" "child_partial_parent_id_fkey" yes
+ expect "foreign key on an index's second column" "$fk" "child_second_parent_id_fkey" yes
+ expect "composite key columns in key order" "$fk" "child_pair_unindexed_fk|y" yes
+ expect "foreign key with an index" "$fk" "child_indexed_parent_id_fkey" no
+ expect "composite key an index covers in another order" "$fk" "child_pair_fk|" no
+
+ local stats
+ stats="$(pg -d "$DATABASE" -c "$(query_of "$source" statisticsResetQuery)")"
+ echo "ok statistics reset query ran: '${stats}'"
+
+ # The fixes exactly as PostgreSQLTableHealthProviderTests pins them.
+ pg -d "$DATABASE" -c 'CREATE INDEX CONCURRENTLY ON "public"."child_unindexed" ("parent_id");' > /dev/null
+ pg -d "$DATABASE" -c 'VACUUM (ANALYZE) "public"."bloated";' > /dev/null
+ pg -d "$DATABASE" -c 'DROP INDEX CONCURRENTLY "public"."no_pk_unused_idx";' > /dev/null
+ pg -d "$DATABASE" -c 'ALTER TABLE "public"."no_pk" ADD PRIMARY KEY (v);' > /dev/null
+
+ fk="$(pg -d "$DATABASE" -c "$(query_of "$source" unindexedForeignKeyQuery)")"
+ expect "the previewed index clears its finding" "$fk" "child_unindexed_parent_id_fkey" no
+ for attempt in 1 2 3 4 5 6 7 8 9 10; do
+ bloat="$(pg -d "$DATABASE" -c "$(query_of "$source" bloatQuery)")"
+ grep -qF "bloated" <<< "$bloat" || break
+ sleep 1
+ done
+ expect "the previewed vacuum clears its finding" "$bloat" "bloated" no
+ unused="$(pg -d "$DATABASE" -c "$(query_of "$source" unusedIndexQuery)")"
+ expect "the previewed drop clears its finding" "$unused" "no_pk_unused_idx" no
+ nopk="$(pg -d "$DATABASE" -c "$(query_of "$source" missingPrimaryKeyQuery)")"
+ expect "the previewed primary key clears its finding" "$nopk" "public|no_pk" no
+}
+
+check_mysql() {
+ local source="$SOURCES/MySQLTableHealthProvider.swift"
+ command -v mysql > /dev/null || { echo "mysql not found" >&2; exit 3; }
+ # MariaDB's client warns on stderr about a passwordless certificate check, which would land in captured output.
+ local tls=()
+ mysql --help 2>/dev/null | grep -q -- "--ssl-verify-server-cert" && tls=(--disable-ssl-verify-server-cert)
+ my() { mysql --protocol=TCP -h "$HOST" -P "$PORT" -u "$USER_NAME" "${tls[@]}" -N -B "$@"; }
+ my -e "SELECT 1" > /dev/null 2>&1 || { echo "no MySQL at $HOST:$PORT" >&2; exit 3; }
+
+ echo "$(my -e 'SELECT VERSION()') at $HOST:$PORT"
+ my -e "DROP DATABASE IF EXISTS $DATABASE; CREATE DATABASE $DATABASE"
+ trap 'mysql --protocol=TCP -h "$HOST" -P "$PORT" -u "$USER_NAME" -e "DROP DATABASE IF EXISTS '"$DATABASE"'" > /dev/null 2>&1' EXIT
+
+ my "$DATABASE" > /dev/null <<'SQL'
+CREATE TABLE parent (id INT PRIMARY KEY);
+CREATE TABLE no_pk (a INT, b INT, KEY no_pk_a_idx (a), KEY no_pk_ab_idx (a, b), UNIQUE KEY no_pk_b_uniq (b));
+CREATE TABLE `odd``name` (v INT);
+CREATE TABLE healthy (id INT PRIMARY KEY, v INT, KEY healthy_v_idx (v));
+INSERT INTO healthy VALUES (1, 1), (2, 2), (3, 3);
+INSERT INTO no_pk VALUES (1, 1), (2, 2);
+SELECT COUNT(*) FROM healthy FORCE INDEX (healthy_v_idx) WHERE v = 2;
+SQL
+ [ $? -eq 0 ] || exit 1
+
+ local ps
+ ps="$(my -e "$(swift_line_literal "$source" performanceSchemaQuery)")"
+ echo "ok performance_schema = $ps"
+ local uptime
+ uptime="$(my -e "$(swift_line_literal "$source" uptimeQuery)")"
+ expect "uptime reads as Uptime and a value" "$uptime" "Uptime" yes
+
+ local unused
+ unused="$(my -e "$(query_of "$source" unusedIndexQuery)")"
+ if [ "$ps" = "1" ]; then
+ expect "unused index lists a plain index" "$unused" "no_pk_a_idx" yes
+ expect "unused index skips a unique index" "$unused" "no_pk_b_uniq" no
+ expect "unused index skips an index a query read" "$unused" "healthy_v_idx" no
+ else
+ echo "note performance_schema is off, so the unused index list is empty: '$unused'"
+ fi
+
+ # The prefix comparison runs in Swift (MySQLTableHealthProviderTests); here the rows it reads.
+ local redundant
+ redundant="$(my -e "$(query_of "$source" redundantIndexQuery)")"
+ expect "index rows carry the first key column" "$redundant" "$DATABASE no_pk no_pk_ab_idx 1 BTREE a " yes
+ expect "index rows carry the second key column" "$redundant" "$DATABASE no_pk no_pk_ab_idx 1 BTREE b " yes
+ expect "index rows mark a unique index" "$redundant" "$DATABASE no_pk no_pk_b_uniq 0 BTREE b " yes
+ expect "index rows skip the system schemas" "$redundant" "mysql " no
+
+ local nopk
+ nopk="$(my -e "$(query_of "$source" missingPrimaryKeyQuery)")"
+ expect "missing primary key lists a heap" "$nopk" "$DATABASE no_pk" yes
+ expect "missing primary key lists a quoted name" "$nopk" "odd\`name" yes
+ expect "missing primary key skips a keyed table" "$nopk" " parent" no
+ expect "missing primary key skips the system schemas" "$nopk" "mysql " no
+
+ # The fixes exactly as MySQLTableHealthProviderTests pins them.
+ my -e "ALTER TABLE \`$DATABASE\`.\`no_pk\` DROP INDEX \`no_pk_a_idx\`;"
+ my -e "ALTER TABLE \`$DATABASE\`.\`odd\`\`name\` ADD PRIMARY KEY (v);"
+ nopk="$(my -e "$(query_of "$source" missingPrimaryKeyQuery)")"
+ expect "the previewed primary key clears its finding" "$nopk" "odd\`name" no
+ redundant="$(my -e "$(query_of "$source" redundantIndexQuery)")"
+ expect "the previewed drop clears its finding" "$redundant" "no_pk_a_idx" no
+}
+
+case "$ENGINE" in
+ postgres) check_postgres ;;
+ mysql) check_mysql ;;
+ *)
+ echo "usage: $0 postgres|mysql " >&2
+ exit 3
+ ;;
+esac
+
+if [ "$FAILURES" -gt 0 ]; then
+ echo "$FAILURES check(s) failed" >&2
+ exit 1
+fi
+echo "PASS"
From bc8a131d97ec794c436803ca703f0c897f751c76 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Nguy=E1=BB=85n=20Nam=20Long?=
<26674308+J2TeamNNL@users.noreply.github.com>
Date: Sun, 1 Nov 2026 03:35:00 +0700
Subject: [PATCH 02/10] docs(changelog): add the Table Health entry
---
apple/changelog.d/3958.added.md | 1 +
1 file changed, 1 insertion(+)
create mode 100644 apple/changelog.d/3958.added.md
diff --git a/apple/changelog.d/3958.added.md b/apple/changelog.d/3958.added.md
new file mode 100644
index 0000000000..d968de9799
--- /dev/null
+++ b/apple/changelog.d/3958.added.md
@@ -0,0 +1 @@
+Table Health lists bloated tables, unused and redundant indexes, and missing keys on PostgreSQL and MySQL.
From 391f0d5651674a27da4dc545402da73c5d7deacc Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Nguy=E1=BB=85n=20Nam=20Long?=
<26674308+J2TeamNNL@users.noreply.github.com>
Date: Sun, 1 Nov 2026 03:49:33 +0700
Subject: [PATCH 03/10] fix(table-health): compare index type and direction,
keep findings a failed check had
---
.../MySQLTableHealthProvider.swift | 15 +++--
.../ViewModels/TableHealthViewModel.swift | 17 +++++-
.../Views/TableHealth/TableHealthView.swift | 5 +-
.../MySQLTableHealthProviderTests.swift | 58 +++++++++++++++----
.../TableHealthViewModelTests.swift | 19 ++++++
5 files changed, 96 insertions(+), 18 deletions(-)
diff --git a/apple/TablePro/Core/TableHealth/MySQLTableHealthProvider.swift b/apple/TablePro/Core/TableHealth/MySQLTableHealthProvider.swift
index e9ad0d477e..13b18ed1ce 100644
--- a/apple/TablePro/Core/TableHealth/MySQLTableHealthProvider.swift
+++ b/apple/TablePro/Core/TableHealth/MySQLTableHealthProvider.swift
@@ -42,7 +42,8 @@ struct MySQLTableHealthProvider: TableHealthProvider {
s.NON_UNIQUE AS non_unique,
s.INDEX_TYPE AS index_type,
s.COLUMN_NAME AS column_name,
- s.SUB_PART AS sub_part
+ s.SUB_PART AS sub_part,
+ s.COLLATION AS sort_order
FROM information_schema.STATISTICS s
WHERE s.TABLE_SCHEMA NOT IN ('mysql', 'sys', 'performance_schema', 'information_schema')
ORDER BY s.TABLE_SCHEMA, s.TABLE_NAME, s.INDEX_NAME, s.SEQ_IN_INDEX
@@ -165,6 +166,7 @@ struct MySQLIndexShape {
struct Column: Equatable {
let name: String
let prefixLength: String
+ let sortOrder: String
}
struct Index {
@@ -177,11 +179,14 @@ struct MySQLIndexShape {
var isPrimary: Bool { name == "PRIMARY" }
- /// Redundant when another index of the same type starts with the same columns. Of two equal
- /// indexes, the unique one stays; of two equal unique or two equal plain ones, the first by name stays.
+ /// Redundant when another index of the same type starts with the same columns in the same
+ /// directions. Only a B-tree serves a lookup on its leading columns, so any other type is
+ /// redundant only to an identical index. Of two equal indexes, the unique one stays; of two
+ /// equal unique or two equal plain ones, the first by name stays.
func isRedundant(to other: Index) -> Bool {
guard other.name != name, !isPrimary, !hasExpression, !other.hasExpression,
other.type == type, columns.count <= other.columns.count,
+ type == "BTREE" || columns.count == other.columns.count,
Array(other.columns.prefix(columns.count)) == columns
else { return false }
if columns.count < other.columns.count { return !isUnique }
@@ -215,7 +220,9 @@ struct MySQLIndexShape {
if column.isEmpty {
tables[tables.count - 1].indexes[last].hasExpression = true
} else {
- tables[tables.count - 1].indexes[last].columns.append(Column(name: column, prefixLength: row["sub_part"]))
+ tables[tables.count - 1].indexes[last].columns.append(
+ Column(name: column, prefixLength: row["sub_part"], sortOrder: row["sort_order"])
+ )
}
}
return tables
diff --git a/apple/TablePro/ViewModels/TableHealthViewModel.swift b/apple/TablePro/ViewModels/TableHealthViewModel.swift
index 06df61088e..c24bc2282c 100644
--- a/apple/TablePro/ViewModels/TableHealthViewModel.swift
+++ b/apple/TablePro/ViewModels/TableHealthViewModel.swift
@@ -80,7 +80,7 @@ final class TableHealthViewModel: ObservableObject {
do {
let execute = try executorSource()
let fresh = try await provider.fetchReport(execute: execute)
- report = fresh
+ report = Self.merging(fresh, over: report)
loadError = nil
lastRefreshDate = Date()
if selectedFinding == nil { selectedFindingId = nil }
@@ -91,4 +91,19 @@ final class TableHealthViewModel: ObservableObject {
loadError = error.localizedDescription
}
}
+
+ /// A check that failed this time keeps the findings it had, shown under its new error.
+ static func merging(_ fresh: TableHealthReport, over previous: TableHealthReport?) -> TableHealthReport {
+ guard let previous else { return fresh }
+ var merged = fresh
+ for index in merged.checks.indices where merged.checks[index].error != nil {
+ if let prior = previous.checks.first(where: { $0.kind == merged.checks[index].kind }) {
+ merged.checks[index].findings = prior.findings
+ }
+ }
+ if merged.statisticsNote == nil {
+ merged.statisticsNote = previous.statisticsNote
+ }
+ return merged
+ }
}
diff --git a/apple/TablePro/Views/TableHealth/TableHealthView.swift b/apple/TablePro/Views/TableHealth/TableHealthView.swift
index 3b75306cb4..bf3445c747 100644
--- a/apple/TablePro/Views/TableHealth/TableHealthView.swift
+++ b/apple/TablePro/Views/TableHealth/TableHealthView.swift
@@ -112,7 +112,7 @@ struct TableHealthView: View {
HStack {
Label(check.kind.title, systemImage: check.kind.systemImage)
Spacer()
- if check.error == nil {
+ if check.error == nil || !check.findings.isEmpty {
Text(verbatim: "\(check.findings.count)")
.monospacedDigit()
}
@@ -145,7 +145,8 @@ struct TableHealthView: View {
Text("No findings.")
.font(.callout)
.foregroundStyle(.secondary)
- } else {
+ }
+ if !check.findings.isEmpty {
ForEach(check.findings) { finding in
VStack(alignment: .leading, spacing: 2) {
Text(verbatim: Self.qualifiedName(finding))
diff --git a/apple/TableProTests/Core/TableHealth/MySQLTableHealthProviderTests.swift b/apple/TableProTests/Core/TableHealth/MySQLTableHealthProviderTests.swift
index 4e361cd0ff..7466ac9d60 100644
--- a/apple/TableProTests/Core/TableHealth/MySQLTableHealthProviderTests.swift
+++ b/apple/TableProTests/Core/TableHealth/MySQLTableHealthProviderTests.swift
@@ -13,10 +13,17 @@ struct MySQLTableHealthProviderTests {
private typealias IndexRow = (index: String, nonUnique: String, type: String, column: String?, subPart: String?)
- private func redundant(in rows: [IndexRow], table: String = "orders") -> [String: String] {
+ private static let indexColumns = [
+ "schema_name", "table_name", "index_name", "non_unique", "index_type", "column_name", "sub_part", "sort_order"
+ ]
+
+ private func redundant(in rows: [IndexRow], table: String = "orders", sortOrders: [String?]? = nil) -> [String: String] {
let result = TableHealthFixture.result(
- columns: ["schema_name", "table_name", "index_name", "non_unique", "index_type", "column_name", "sub_part"],
- rows: rows.map { row -> [String?] in ["shop", table, row.index, row.nonUnique, row.type, row.column, row.subPart] }
+ columns: Self.indexColumns,
+ rows: rows.enumerated().map { position, row -> [String?] in
+ let sortOrder = sortOrders?[position] ?? (row.type == "BTREE" ? "A" : nil)
+ return ["shop", table, row.index, row.nonUnique, row.type, row.column, row.subPart, sortOrder]
+ }
)
var map: [String: String] = [:]
for finding in provider.findings(for: .redundantIndex, from: result) {
@@ -88,14 +95,43 @@ struct MySQLTableHealthProviderTests {
#expect(found.isEmpty)
}
+ @Test("A full-text index is redundant only to an identical one, never to a longer one")
+ func fullTextNeedsExactColumns() {
+ let prefix = redundant(in: [
+ ("ft_a", "1", "FULLTEXT", "a", nil),
+ ("ft_ab", "1", "FULLTEXT", "a", nil),
+ ("ft_ab", "1", "FULLTEXT", "b", nil)
+ ])
+ let identical = redundant(in: [
+ ("ft_a", "1", "FULLTEXT", "a", nil),
+ ("ft_a2", "1", "FULLTEXT", "a", nil)
+ ])
+ #expect(prefix.isEmpty)
+ #expect(Array(identical.keys) == ["ft_a2"])
+ }
+
+ @Test("A key part sorted the other way keeps an index apart")
+ func sortDirectionMatters() {
+ let found = redundant(
+ in: [
+ ("idx_ab_desc", "1", "BTREE", "a", nil),
+ ("idx_ab_desc", "1", "BTREE", "b", nil),
+ ("idx_ab", "1", "BTREE", "a", nil),
+ ("idx_ab", "1", "BTREE", "b", nil)
+ ],
+ sortOrders: ["A", "D", "A", "A"]
+ )
+ #expect(found.isEmpty)
+ }
+
@Test("Indexes compare only within their own table")
func tablesStayApart() {
let result = TableHealthFixture.result(
- columns: ["schema_name", "table_name", "index_name", "non_unique", "index_type", "column_name", "sub_part"],
+ columns: Self.indexColumns,
rows: [
- ["shop", "a", "idx_x", "1", "BTREE", "c", nil],
- ["shop", "b", "idx_xy", "1", "BTREE", "c", nil],
- ["shop", "b", "idx_xy", "1", "BTREE", "d", nil]
+ ["shop", "a", "idx_x", "1", "BTREE", "c", nil, "A"],
+ ["shop", "b", "idx_xy", "1", "BTREE", "c", nil, "A"],
+ ["shop", "b", "idx_xy", "1", "BTREE", "d", nil, "A"]
]
)
#expect(provider.findings(for: .redundantIndex, from: result).isEmpty)
@@ -104,11 +140,11 @@ struct MySQLTableHealthProviderTests {
@Test("A redundant index previews a drop with backtick quoting")
func redundantFixQuotes() throws {
let result = TableHealthFixture.result(
- columns: ["schema_name", "table_name", "index_name", "non_unique", "index_type", "column_name", "sub_part"],
+ columns: Self.indexColumns,
rows: [
- ["shop", "odd`name", "idx_a", "1", "BTREE", "a", nil],
- ["shop", "odd`name", "idx_ab", "1", "BTREE", "a", nil],
- ["shop", "odd`name", "idx_ab", "1", "BTREE", "b", nil]
+ ["shop", "odd`name", "idx_a", "1", "BTREE", "a", nil, "A"],
+ ["shop", "odd`name", "idx_ab", "1", "BTREE", "a", nil, "A"],
+ ["shop", "odd`name", "idx_ab", "1", "BTREE", "b", nil, "A"]
]
)
let finding = try #require(provider.findings(for: .redundantIndex, from: result).first)
diff --git a/apple/TableProTests/ViewModels/TableHealthViewModelTests.swift b/apple/TableProTests/ViewModels/TableHealthViewModelTests.swift
index 122536f5cc..8c9148e562 100644
--- a/apple/TableProTests/ViewModels/TableHealthViewModelTests.swift
+++ b/apple/TableProTests/ViewModels/TableHealthViewModelTests.swift
@@ -19,6 +19,7 @@ struct TableHealthViewModelTests {
private final class FakeServer {
var tablesWithoutKey: [String] = ["logs"]
var isOnline = true
+ var failsMissingKeyCheck = false
private(set) var queryCount = 0
func executorSource() throws -> TableHealthViewModel.Execute {
@@ -28,6 +29,7 @@ struct TableHealthViewModelTests {
guard query == PostgreSQLTableHealthProvider.missingPrimaryKeyQuery else {
return TableHealthFixture.result(columns: [], rows: [])
}
+ if failsMissingKeyCheck { throw Offline() }
return TableHealthFixture.result(
columns: ["schema_name", "table_name"],
rows: tablesWithoutKey.map { table -> [String?] in ["public", table] }
@@ -99,6 +101,23 @@ struct TableHealthViewModelTests {
#expect(viewModel.loadError != nil)
}
+ @Test("A check that fails on refresh keeps its findings and its selection, under the error")
+ func failedCheckKeepsFindings() async throws {
+ let server = FakeServer()
+ let viewModel = makeViewModel(server)
+ await viewModel.refresh()
+ let logs = try #require(viewModel.report?.checks.flatMap { $0.findings }.first)
+ viewModel.selectedFindingId = logs.id
+
+ server.failsMissingKeyCheck = true
+ await viewModel.refresh()
+
+ let check = try #require(viewModel.report?.checks.first { $0.kind == .missingPrimaryKey })
+ #expect(check.error == "No active connection")
+ #expect(check.findings.map { $0.table } == ["logs"])
+ #expect(viewModel.selectedFinding?.table == "logs")
+ }
+
@Test("A refresh drops the selection when its finding is gone and keeps it otherwise")
func selectionFollowsFindings() async throws {
let server = FakeServer()
From 5d611498945b7b5c5f036acddd5299ab8e7abbbd Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Nguy=E1=BB=85n=20Nam=20Long?=
<26674308+J2TeamNNL@users.noreply.github.com>
Date: Sun, 1 Nov 2026 03:54:53 +0700
Subject: [PATCH 04/10] fix(table-health): keep the report's database and skip
INCLUDE columns in key coverage
---
.../MySQLTableHealthProvider.swift | 2 ++
.../PostgreSQLTableHealthProvider.swift | 13 ++++++++++--
.../TableHealth/TableHealthProvider.swift | 10 ++++++++-
.../TableHealth/TableHealthModels.swift | 2 ++
.../ViewModels/TableHealthViewModel.swift | 2 +-
.../MainContentCoordinator+TableHealth.swift | 9 ++++----
.../Views/TableHealth/TableHealthView.swift | 16 ++++++++++++--
.../PostgreSQLTableHealthProviderTests.swift | 21 +++++++++++++++++++
.../TableHealthViewModelTests.swift | 19 +++++++++++++++++
.../probes/check-table-health-catalogs.sh | 13 ++++++++++++
10 files changed, 97 insertions(+), 10 deletions(-)
diff --git a/apple/TablePro/Core/TableHealth/MySQLTableHealthProvider.swift b/apple/TablePro/Core/TableHealth/MySQLTableHealthProvider.swift
index 13b18ed1ce..90ef630890 100644
--- a/apple/TablePro/Core/TableHealth/MySQLTableHealthProvider.swift
+++ b/apple/TablePro/Core/TableHealth/MySQLTableHealthProvider.swift
@@ -71,6 +71,8 @@ struct MySQLTableHealthProvider: TableHealthProvider {
var statisticsQueries: [String] { [Self.performanceSchemaQuery, Self.uptimeQuery] }
+ var databaseQuery: String? { nil }
+
func query(for check: TableHealthCheckKind) -> String {
switch check {
case .unusedIndex: return Self.unusedIndexQuery
diff --git a/apple/TablePro/Core/TableHealth/PostgreSQLTableHealthProvider.swift b/apple/TablePro/Core/TableHealth/PostgreSQLTableHealthProvider.swift
index 8d0f342603..b823b2b112 100644
--- a/apple/TablePro/Core/TableHealth/PostgreSQLTableHealthProvider.swift
+++ b/apple/TablePro/Core/TableHealth/PostgreSQLTableHealthProvider.swift
@@ -55,8 +55,10 @@ struct PostgreSQLTableHealthProvider: TableHealthProvider {
LIMIT 500
"""
- // One row per referencing column. An index covers the key when its leading columns are the
- // key's columns in any order; a partial index does not, since it skips rows.
+ // One row per referencing column. An index covers the key when its leading key columns are the
+ // key's columns in any order. A partial or invalid index does not, and neither does an INCLUDE
+ // column, which is stored but not searchable; indnkeyatts is read through row_to_json because
+ // servers before 11 have no such column.
static let unindexedForeignKeyQuery = """
SELECT n.nspname AS schema_name,
c.relname AS table_name,
@@ -72,6 +74,9 @@ struct PostgreSQLTableHealthProvider: TableHealthProvider {
SELECT 1 FROM pg_catalog.pg_index i
WHERE i.indrelid = con.conrelid
AND i.indpred IS NULL
+ AND i.indisvalid
+ AND pg_catalog.array_length(con.conkey, 1)
+ <= COALESCE((pg_catalog.row_to_json(i) ->> 'indnkeyatts')::pg_catalog.int4, i.indnatts)
AND (i.indkey::pg_catalog.int2[])[0:pg_catalog.array_length(con.conkey, 1) - 1] @> con.conkey
)
ORDER BY n.nspname, c.relname, con.conname, k.position
@@ -83,8 +88,12 @@ struct PostgreSQLTableHealthProvider: TableHealthProvider {
WHERE datname = pg_catalog.current_database()
"""
+ static let databaseNameQuery = "SELECT pg_catalog.current_database() AS database_name"
+
var statisticsQueries: [String] { [Self.statisticsResetQuery] }
+ var databaseQuery: String? { Self.databaseNameQuery }
+
func query(for check: TableHealthCheckKind) -> String {
switch check {
case .bloat: return Self.bloatQuery
diff --git a/apple/TablePro/Core/TableHealth/TableHealthProvider.swift b/apple/TablePro/Core/TableHealth/TableHealthProvider.swift
index 5d7fc9303b..1bbd6bd7f0 100644
--- a/apple/TablePro/Core/TableHealth/TableHealthProvider.swift
+++ b/apple/TablePro/Core/TableHealth/TableHealthProvider.swift
@@ -13,6 +13,8 @@ protocol TableHealthProvider: Sendable {
/// MySQL calls a database a schema, so a finding's schema is the database its table opens in.
var schemaNamesDatabase: Bool { get }
var statisticsQueries: [String] { get }
+ /// Names the database the catalogs describe, for an engine whose report covers one database only.
+ var databaseQuery: String? { get }
func query(for check: TableHealthCheckKind) -> String
func findings(for check: TableHealthCheckKind, from result: QueryResult) -> [TableHealthFinding]
func statisticsNote(from results: [QueryResult], now: Date) -> String?
@@ -25,6 +27,12 @@ extension TableHealthProvider {
execute: @MainActor (String) async throws -> QueryResult,
now: Date = Date()
) async throws -> TableHealthReport {
+ var databaseName: String?
+ if let databaseQuery {
+ let result = try await execute(databaseQuery)
+ databaseName = TableHealthRow.rows(of: result).first?["database_name"]
+ }
+
var results: [TableHealthCheckResult] = []
for check in checks {
do {
@@ -51,7 +59,7 @@ extension TableHealthProvider {
? statisticsNote(from: statisticsResults, now: now)
: nil
- return TableHealthReport(checks: results, statisticsNote: note)
+ return TableHealthReport(checks: results, databaseName: databaseName, statisticsNote: note)
}
}
diff --git a/apple/TablePro/Models/TableHealth/TableHealthModels.swift b/apple/TablePro/Models/TableHealth/TableHealthModels.swift
index db130ce54c..fd3f84c583 100644
--- a/apple/TablePro/Models/TableHealth/TableHealthModels.swift
+++ b/apple/TablePro/Models/TableHealth/TableHealthModels.swift
@@ -62,6 +62,8 @@ struct TableHealthCheckResult: Identifiable {
struct TableHealthReport {
var checks: [TableHealthCheckResult]
+ /// The database the server answered for, so a finding opens and fixes there even after the sidebar moves on.
+ var databaseName: String?
/// Index usage counts only since the statistics were last reset, so an index can look unused because they were.
var statisticsNote: String?
diff --git a/apple/TablePro/ViewModels/TableHealthViewModel.swift b/apple/TablePro/ViewModels/TableHealthViewModel.swift
index c24bc2282c..060f23106e 100644
--- a/apple/TablePro/ViewModels/TableHealthViewModel.swift
+++ b/apple/TablePro/ViewModels/TableHealthViewModel.swift
@@ -94,7 +94,7 @@ final class TableHealthViewModel: ObservableObject {
/// A check that failed this time keeps the findings it had, shown under its new error.
static func merging(_ fresh: TableHealthReport, over previous: TableHealthReport?) -> TableHealthReport {
- guard let previous else { return fresh }
+ guard let previous, previous.databaseName == fresh.databaseName else { return fresh }
var merged = fresh
for index in merged.checks.indices where merged.checks[index].error != nil {
if let prior = previous.checks.first(where: { $0.kind == merged.checks[index].kind }) {
diff --git a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+TableHealth.swift b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+TableHealth.swift
index 2b4beba778..eb2b308785 100644
--- a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+TableHealth.swift
+++ b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+TableHealth.swift
@@ -24,20 +24,21 @@ extension MainContentCoordinator {
WindowManager.shared.openTab(payload: payload)
}
- func openTableHealthFinding(_ finding: TableHealthFinding, schemaNamesDatabase: Bool) {
+ /// `reportDatabase` is the database the report was read from, which the sidebar may have left since.
+ func openTableHealthFinding(_ finding: TableHealthFinding, reportDatabase: String?, schemaNamesDatabase: Bool) {
if schemaNamesDatabase {
openTableTab(finding.table, database: finding.schema)
} else {
- openTableTab(finding.table, schema: finding.schema)
+ openTableTab(finding.table, schema: finding.schema, database: reportDatabase)
}
}
/// Puts the fix in an editor tab and stops there: the user reads it and runs it themselves.
- func openTableHealthFix(_ finding: TableHealthFinding, schemaNamesDatabase: Bool) {
+ func openTableHealthFix(_ finding: TableHealthFinding, reportDatabase: String?, schemaNamesDatabase: Bool) {
openQuery(
finding.fixSQL,
on: connectionId,
- databaseName: schemaNamesDatabase ? finding.schema : browseDatabaseName,
+ databaseName: schemaNamesDatabase ? finding.schema : reportDatabase ?? browseDatabaseName,
intent: .loadIntoFrontTab
)
}
diff --git a/apple/TablePro/Views/TableHealth/TableHealthView.swift b/apple/TablePro/Views/TableHealth/TableHealthView.swift
index bf3445c747..41d4d867f5 100644
--- a/apple/TablePro/Views/TableHealth/TableHealthView.swift
+++ b/apple/TablePro/Views/TableHealth/TableHealthView.swift
@@ -51,6 +51,10 @@ struct TableHealthView: View {
VStack(spacing: 0) {
HStack(spacing: 8) {
if let report = viewModel.report {
+ if let database = report.databaseName {
+ Text(verbatim: database)
+ .font(.callout.weight(.semibold))
+ }
Text(String(format: String(localized: "%lld findings"), report.findingCount))
.font(.callout)
.monospacedDigit()
@@ -208,11 +212,19 @@ struct TableHealthView: View {
}
private func openTable(_ finding: TableHealthFinding) {
- coordinator.openTableHealthFinding(finding, schemaNamesDatabase: schemaNamesDatabase)
+ coordinator.openTableHealthFinding(
+ finding,
+ reportDatabase: viewModel.report?.databaseName,
+ schemaNamesDatabase: schemaNamesDatabase
+ )
}
private func openFix(_ finding: TableHealthFinding) {
- coordinator.openTableHealthFix(finding, schemaNamesDatabase: schemaNamesDatabase)
+ coordinator.openTableHealthFix(
+ finding,
+ reportDatabase: viewModel.report?.databaseName,
+ schemaNamesDatabase: schemaNamesDatabase
+ )
}
private func copyFix(_ finding: TableHealthFinding) {
diff --git a/apple/TableProTests/Core/TableHealth/PostgreSQLTableHealthProviderTests.swift b/apple/TableProTests/Core/TableHealth/PostgreSQLTableHealthProviderTests.swift
index 9f7a11446c..4a2db6d44e 100644
--- a/apple/TableProTests/Core/TableHealth/PostgreSQLTableHealthProviderTests.swift
+++ b/apple/TableProTests/Core/TableHealth/PostgreSQLTableHealthProviderTests.swift
@@ -128,6 +128,27 @@ struct PostgreSQLTableHealthProviderTests {
#expect(report.findingCount == 1)
}
+ @Test("The report names the database the server answered for")
+ func reportNamesDatabase() async throws {
+ let report = try await provider.fetchReport { query in
+ if query == PostgreSQLTableHealthProvider.databaseNameQuery {
+ return TableHealthFixture.result(columns: ["database_name"], rows: [["shop"]])
+ }
+ return TableHealthFixture.result(columns: [], rows: [])
+ }
+ #expect(report.databaseName == "shop")
+ }
+
+ @Test("Without the database name the refresh fails, rather than act on a database it cannot name")
+ func databaseNameRequired() async {
+ await #expect(throws: QueryFailure.self) {
+ _ = try await provider.fetchReport { query in
+ if query == PostgreSQLTableHealthProvider.databaseNameQuery { throw QueryFailure() }
+ return TableHealthFixture.result(columns: [], rows: [])
+ }
+ }
+ }
+
@Test("A cancelled refresh stops instead of reporting every check as failed")
func cancellationPropagates() async {
await #expect(throws: CancellationError.self) {
diff --git a/apple/TableProTests/ViewModels/TableHealthViewModelTests.swift b/apple/TableProTests/ViewModels/TableHealthViewModelTests.swift
index 8c9148e562..b80db31606 100644
--- a/apple/TableProTests/ViewModels/TableHealthViewModelTests.swift
+++ b/apple/TableProTests/ViewModels/TableHealthViewModelTests.swift
@@ -20,12 +20,16 @@ struct TableHealthViewModelTests {
var tablesWithoutKey: [String] = ["logs"]
var isOnline = true
var failsMissingKeyCheck = false
+ var database = "shop"
private(set) var queryCount = 0
func executorSource() throws -> TableHealthViewModel.Execute {
guard isOnline else { throw Offline() }
return { [self] query in
queryCount += 1
+ if query == PostgreSQLTableHealthProvider.databaseNameQuery {
+ return TableHealthFixture.result(columns: ["database_name"], rows: [[database]])
+ }
guard query == PostgreSQLTableHealthProvider.missingPrimaryKeyQuery else {
return TableHealthFixture.result(columns: [], rows: [])
}
@@ -118,6 +122,21 @@ struct TableHealthViewModelTests {
#expect(viewModel.selectedFinding?.table == "logs")
}
+ @Test("Findings from another database are not carried over when a check fails")
+ func otherDatabaseFindingsDropped() async {
+ let server = FakeServer()
+ let viewModel = makeViewModel(server)
+ await viewModel.refresh()
+
+ server.database = "archive"
+ server.failsMissingKeyCheck = true
+ await viewModel.refresh()
+
+ #expect(viewModel.report?.databaseName == "archive")
+ #expect(viewModel.report?.findingCount == 0)
+ #expect(viewModel.selectedFindingId == nil)
+ }
+
@Test("A refresh drops the selection when its finding is gone and keeps it otherwise")
func selectionFollowsFindings() async throws {
let server = FakeServer()
diff --git a/apple/scripts/probes/check-table-health-catalogs.sh b/apple/scripts/probes/check-table-health-catalogs.sh
index c83748395e..a81e88bd4f 100755
--- a/apple/scripts/probes/check-table-health-catalogs.sh
+++ b/apple/scripts/probes/check-table-health-catalogs.sh
@@ -85,6 +85,7 @@ CREATE TABLE child_partial (id int PRIMARY KEY, parent_id int REFERENCES parent
CREATE INDEX ON child_partial (parent_id) WHERE parent_id > 0;
CREATE TABLE child_second (id int PRIMARY KEY, other int, parent_id int REFERENCES parent (id));
CREATE INDEX ON child_second (other, parent_id);
+CREATE TABLE child_include (id int PRIMARY KEY, x int, y int, CONSTRAINT child_include_fk FOREIGN KEY (x, y) REFERENCES pair (x, y));
CREATE TABLE no_pk (v int);
CREATE INDEX no_pk_unused_idx ON no_pk (v);
CREATE UNIQUE INDEX no_pk_unique_idx ON no_pk ((v + 1));
@@ -127,8 +128,20 @@ SQL
expect "foreign key on an index's second column" "$fk" "child_second_parent_id_fkey" yes
expect "composite key columns in key order" "$fk" "child_pair_unindexed_fk|y" yes
expect "foreign key with an index" "$fk" "child_indexed_parent_id_fkey" no
+ if [ "$(pg -d "$DATABASE" -c 'SHOW server_version_num')" -ge 110000 ]; then
+ pg -d "$DATABASE" -c 'CREATE INDEX ON child_include (x) INCLUDE (y)' > /dev/null
+ fk="$(pg -d "$DATABASE" -c "$(query_of "$source" unindexedForeignKeyQuery)")"
+ expect "an INCLUDE column does not cover a key column" "$fk" "child_include_fk" yes
+ pg -d "$DATABASE" -c 'CREATE INDEX ON child_include (y, x)' > /dev/null
+ fk="$(pg -d "$DATABASE" -c "$(query_of "$source" unindexedForeignKeyQuery)")"
+ expect "key columns in an index cover the key" "$fk" "child_include_fk" no
+ fi
expect "composite key an index covers in another order" "$fk" "child_pair_fk|" no
+ local name
+ name="$(pg -d "$DATABASE" -c "$(swift_line_literal "$source" databaseNameQuery)")"
+ expect "the database name query names this database" "$name" "$DATABASE" yes
+
local stats
stats="$(pg -d "$DATABASE" -c "$(query_of "$source" statisticsResetQuery)")"
echo "ok statistics reset query ran: '${stats}'"
From 344170436464b4fe330f8df1f1ac247462b8446b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Nguy=E1=BB=85n=20Nam=20Long?=
<26674308+J2TeamNNL@users.noreply.github.com>
Date: Sun, 1 Nov 2026 04:20:02 +0700
Subject: [PATCH 05/10] fix(table-health): drop a refresh that changed
database, skip attached partition indexes
---
.../PostgreSQLTableHealthProvider.swift | 4 +++-
.../Core/TableHealth/TableHealthProvider.swift | 16 ++++++++++++++++
apple/TablePro/Resources/Localizable.xcstrings | 3 +++
.../Views/TableHealth/TableHealthView.swift | 14 ++++++++------
.../PostgreSQLTableHealthProviderTests.swift | 13 +++++++++++++
.../probes/check-table-health-catalogs.sh | 10 ++++++++++
6 files changed, 53 insertions(+), 7 deletions(-)
diff --git a/apple/TablePro/Core/TableHealth/PostgreSQLTableHealthProvider.swift b/apple/TablePro/Core/TableHealth/PostgreSQLTableHealthProvider.swift
index b823b2b112..bcc49da76f 100644
--- a/apple/TablePro/Core/TableHealth/PostgreSQLTableHealthProvider.swift
+++ b/apple/TablePro/Core/TableHealth/PostgreSQLTableHealthProvider.swift
@@ -24,7 +24,8 @@ struct PostgreSQLTableHealthProvider: TableHealthProvider {
LIMIT 500
"""
- // A unique or constraint index enforces something even when no query reads it.
+ // A unique or constraint index enforces something even when no query reads it. An index on a
+ // partition that a parent index owns cannot be dropped on its own.
static let unusedIndexQuery = """
SELECT s.schemaname AS schema_name,
s.relname AS table_name,
@@ -35,6 +36,7 @@ struct PostgreSQLTableHealthProvider: TableHealthProvider {
WHERE s.idx_scan = 0
AND NOT i.indisunique
AND NOT EXISTS (SELECT 1 FROM pg_catalog.pg_constraint c WHERE c.conindid = s.indexrelid)
+ AND NOT EXISTS (SELECT 1 FROM pg_catalog.pg_inherits h WHERE h.inhrelid = s.indexrelid)
ORDER BY index_bytes DESC, schema_name, index_name
LIMIT 500
"""
diff --git a/apple/TablePro/Core/TableHealth/TableHealthProvider.swift b/apple/TablePro/Core/TableHealth/TableHealthProvider.swift
index 1bbd6bd7f0..33dfab3612 100644
--- a/apple/TablePro/Core/TableHealth/TableHealthProvider.swift
+++ b/apple/TablePro/Core/TableHealth/TableHealthProvider.swift
@@ -59,10 +59,26 @@ extension TableHealthProvider {
? statisticsNote(from: statisticsResults, now: now)
: nil
+ // The connection can move to another database between queries; a report that spans two would
+ // send one database's fixes to the other.
+ if let databaseQuery {
+ let result = try await execute(databaseQuery)
+ let finalName = TableHealthRow.rows(of: result).first?["database_name"]
+ guard finalName == databaseName else { throw TableHealthError.databaseChanged }
+ }
+
return TableHealthReport(checks: results, databaseName: databaseName, statisticsNote: note)
}
}
+enum TableHealthError: LocalizedError {
+ case databaseChanged
+
+ var errorDescription: String? {
+ String(localized: "The connection switched databases during the refresh. Refresh again.")
+ }
+}
+
enum TableHealthProviderFactory {
static func provider(for databaseType: DatabaseType) -> TableHealthProvider? {
switch databaseType {
diff --git a/apple/TablePro/Resources/Localizable.xcstrings b/apple/TablePro/Resources/Localizable.xcstrings
index a33c0a511c..63916cf98c 100644
--- a/apple/TablePro/Resources/Localizable.xcstrings
+++ b/apple/TablePro/Resources/Localizable.xcstrings
@@ -184831,6 +184831,9 @@
}
}
}
+ },
+ "The connection switched databases during the refresh. Refresh again." : {
+
},
"The connection this query was recorded against no longer exists." : {
"comment" : "Error message shown when a query cannot be opened because the connection it was recorded against no longer exists.",
diff --git a/apple/TablePro/Views/TableHealth/TableHealthView.swift b/apple/TablePro/Views/TableHealth/TableHealthView.swift
index 41d4d867f5..1bfe0007d2 100644
--- a/apple/TablePro/Views/TableHealth/TableHealthView.swift
+++ b/apple/TablePro/Views/TableHealth/TableHealthView.swift
@@ -4,14 +4,16 @@ struct TableHealthView: View {
@ObservedObject var viewModel: TableHealthViewModel
@ObservedObject var coordinator: MainContentCoordinator
+ // A VStack, not safeAreaInset: the inset does not reach the List inside VSplitView, whose first rows then sit under the bar.
var body: some View {
- content
- .safeAreaInset(edge: .top, spacing: 0) {
- if viewModel.isSupported {
- toolbar
- }
+ VStack(spacing: 0) {
+ if viewModel.isSupported {
+ toolbar
}
- .task { await viewModel.loadIfNeeded() }
+ content
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .task { await viewModel.loadIfNeeded() }
}
@ViewBuilder
diff --git a/apple/TableProTests/Core/TableHealth/PostgreSQLTableHealthProviderTests.swift b/apple/TableProTests/Core/TableHealth/PostgreSQLTableHealthProviderTests.swift
index 4a2db6d44e..28f653f010 100644
--- a/apple/TableProTests/Core/TableHealth/PostgreSQLTableHealthProviderTests.swift
+++ b/apple/TableProTests/Core/TableHealth/PostgreSQLTableHealthProviderTests.swift
@@ -149,6 +149,19 @@ struct PostgreSQLTableHealthProviderTests {
}
}
+ @Test("A refresh that ends on another database than it began on is discarded")
+ func databaseSwitchDiscardsReport() async {
+ var answers = ["shop", "archive"]
+ await #expect(throws: TableHealthError.self) {
+ _ = try await provider.fetchReport { query in
+ if query == PostgreSQLTableHealthProvider.databaseNameQuery {
+ return TableHealthFixture.result(columns: ["database_name"], rows: [[answers.removeFirst()]])
+ }
+ return TableHealthFixture.result(columns: [], rows: [])
+ }
+ }
+ }
+
@Test("A cancelled refresh stops instead of reporting every check as failed")
func cancellationPropagates() async {
await #expect(throws: CancellationError.self) {
diff --git a/apple/scripts/probes/check-table-health-catalogs.sh b/apple/scripts/probes/check-table-health-catalogs.sh
index a81e88bd4f..04da7ddbf9 100755
--- a/apple/scripts/probes/check-table-health-catalogs.sh
+++ b/apple/scripts/probes/check-table-health-catalogs.sh
@@ -113,6 +113,16 @@ SQL
expect "unused index skips a unique index" "$unused" "no_pk_unique_idx" no
expect "unused index skips a primary key" "$unused" "parent_pkey" no
expect "unused index skips a unique constraint" "$unused" "parent_code_key" no
+ if [ "$(pg -d "$DATABASE" -c 'SHOW server_version_num')" -ge 110000 ]; then
+ pg -d "$DATABASE" > /dev/null <<'PART'
+CREATE TABLE events (at date, kind text) PARTITION BY RANGE (at);
+CREATE TABLE events_2026 PARTITION OF events FOR VALUES FROM ('2026-01-01') TO ('2027-01-01');
+CREATE INDEX events_kind_idx ON events (kind);
+PART
+ unused="$(pg -d "$DATABASE" -c "$(query_of "$source" unusedIndexQuery)")"
+ expect "unused index skips a partition index its parent owns" "$unused" "events_2026_kind_idx" no
+ pg -d "$DATABASE" -c 'DROP TABLE events' > /dev/null
+ fi
local nopk
nopk="$(pg -d "$DATABASE" -c "$(query_of "$source" missingPrimaryKeyQuery)")"
From 675f2eec83ed9c2a3a2cd2e094272c91e57f4f5a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Nguy=E1=BB=85n=20Nam=20Long?=
<26674308+J2TeamNNL@users.noreply.github.com>
Date: Sun, 1 Nov 2026 04:23:03 +0700
Subject: [PATCH 06/10] fix(table-health): drop the unused loop variables in
the catalog probe
---
apple/scripts/probes/check-table-health-catalogs.sh | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/apple/scripts/probes/check-table-health-catalogs.sh b/apple/scripts/probes/check-table-health-catalogs.sh
index 04da7ddbf9..a740f68df4 100755
--- a/apple/scripts/probes/check-table-health-catalogs.sh
+++ b/apple/scripts/probes/check-table-health-catalogs.sh
@@ -98,8 +98,8 @@ INSERT INTO healthy SELECT g FROM generate_series(1, 5000) g;
DELETE FROM healthy WHERE id <= 10;
SQL
- local bloat="" attempt
- for attempt in 1 2 3 4 5 6 7 8 9 10; do
+ local bloat=""
+ for _ in 1 2 3 4 5 6 7 8 9 10; do
bloat="$(pg -d "$DATABASE" -c "$(query_of "$source" bloatQuery)")"
grep -qF "bloated" <<< "$bloat" && break
sleep 1
@@ -164,7 +164,7 @@ PART
fk="$(pg -d "$DATABASE" -c "$(query_of "$source" unindexedForeignKeyQuery)")"
expect "the previewed index clears its finding" "$fk" "child_unindexed_parent_id_fkey" no
- for attempt in 1 2 3 4 5 6 7 8 9 10; do
+ for _ in 1 2 3 4 5 6 7 8 9 10; do
bloat="$(pg -d "$DATABASE" -c "$(query_of "$source" bloatQuery)")"
grep -qF "bloated" <<< "$bloat" || break
sleep 1
From 66af14de6be1f62ff248c7fdaa0b9f77c8d06e7a Mon Sep 17 00:00:00 2001
From: Ngo Quoc Dat
Date: Sun, 1 Nov 2026 05:57:50 +0700
Subject: [PATCH 07/10] refactor(table-health): read on a scoped metadata lease
and share the tool tab paths
Fixes the review of #3958: foreign key and partition rules, skipped checks when usage is not counted, Refresh on self-loading tabs, per-engine menu gate.
---
apple/TablePro/Core/Events/AppEvents.swift | 6 +-
.../Infrastructure/EditorTabOpener.swift | 10 +-
...inSplitViewController+MenuValidation.swift | 9 +-
.../Infrastructure/WindowTitleResolver.swift | 12 +
.../MySQLTableHealthProvider.swift | 302 ++++++++++-------
.../PostgreSQLTableHealthProvider.swift | 227 +++++++------
.../TableHealth/TableHealthProvider.swift | 130 +++----
.../Models/Query/QueryTabManager.swift | 46 +--
.../TableHealth/TableHealthModels.swift | 56 ++--
.../TablePro/Resources/Localizable.xcstrings | 26 +-
.../ViewModels/TableHealthViewModel.swift | 88 ++---
.../ViewModels/TableListingViewModel.swift | 2 +-
.../Components/PaneRefreshControls.swift | 31 ++
.../Main/Child/MainEditorContentView.swift | 34 +-
.../MainContentCoordinator+Navigation.swift | 2 +-
...MainContentCoordinator+QueryInsights.swift | 34 --
.../MainContentCoordinator+Refresh.swift | 17 +-
...inContentCoordinator+ServerDashboard.swift | 36 --
...MainContentCoordinator+SingletonTabs.swift | 24 ++
.../MainContentCoordinator+TableHealth.swift | 45 ---
.../MainContentCoordinator+UsersRoles.swift | 28 --
...nContentCommandActions+DatabaseTools.swift | 13 +-
.../ObjectSource/ObjectSourceTabView.swift | 7 +
.../QueryInsights/QueryInsightsToolbar.swift | 33 +-
.../Views/TableHealth/TableHealthView.swift | 253 +++++++-------
.../VersionHistoryTabView.swift | 2 +-
.../Core/Menu/MainMenuBuilderTests.swift | 25 ++
.../Storage/QueryHistoryInsightsTests.swift | 4 +-
.../MySQLTableHealthProviderTests.swift | 317 ++++++++++++------
.../PostgreSQLTableHealthProviderTests.swift | 171 +++++-----
.../TableHealth/TableHealthTestSupport.swift | 17 +-
...QueryTabManagerVisibleContainerTests.swift | 2 +-
.../Query/TabStructureVersionTests.swift | 6 +-
.../TableHealthViewModelTests.swift | 212 ++++++++----
.../Views/DetailSurfaceThemeTests.swift | 1 +
.../Views/Editor/PaneBarThemeTests.swift | 1 +
.../Main/CoordinatorTableListingTests.swift | 4 +-
.../MainContentCoordinatorRefreshTests.swift | 36 +-
apple/changelog.d/3958.added.md | 2 +-
apple/changelog.d/3958.fixed.md | 1 +
.../probes/check-table-health-catalogs.sh | 215 +++++++-----
41 files changed, 1403 insertions(+), 1084 deletions(-)
create mode 100644 apple/TablePro/Views/Components/PaneRefreshControls.swift
delete mode 100644 apple/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryInsights.swift
delete mode 100644 apple/TablePro/Views/Main/Extensions/MainContentCoordinator+ServerDashboard.swift
create mode 100644 apple/TablePro/Views/Main/Extensions/MainContentCoordinator+SingletonTabs.swift
delete mode 100644 apple/TablePro/Views/Main/Extensions/MainContentCoordinator+TableHealth.swift
delete mode 100644 apple/TablePro/Views/Main/Extensions/MainContentCoordinator+UsersRoles.swift
create mode 100644 apple/changelog.d/3958.fixed.md
diff --git a/apple/TablePro/Core/Events/AppEvents.swift b/apple/TablePro/Core/Events/AppEvents.swift
index 6247125ea1..4e748bd464 100644
--- a/apple/TablePro/Core/Events/AppEvents.swift
+++ b/apple/TablePro/Core/Events/AppEvents.swift
@@ -128,10 +128,8 @@ final class AppEvents {
/// uniformly handle "this update may affect me" via `payload == nil || payload == self.connectionId`.
let linkedSQLFoldersDidUpdate = PassthroughSubject()
- let versionHistoryRefreshRequested = PassthroughSubject()
-
- /// Refresh on a table listing tab, which loads its own rows rather than through the coordinator.
- let tableListingRefreshRequested = PassthroughSubject()
+ // Refresh on a tab that loads its own content rather than through the coordinator, by tab id.
+ let tabRefreshRequested = PassthroughSubject()
// MARK: - License & Sync
diff --git a/apple/TablePro/Core/Services/Infrastructure/EditorTabOpener.swift b/apple/TablePro/Core/Services/Infrastructure/EditorTabOpener.swift
index 0256ad78c0..bb74abcead 100644
--- a/apple/TablePro/Core/Services/Infrastructure/EditorTabOpener.swift
+++ b/apple/TablePro/Core/Services/Infrastructure/EditorTabOpener.swift
@@ -90,14 +90,8 @@ internal enum EditorTabOpener {
databaseName: payload.databaseName ?? openingDatabaseName,
schemaName: payload.schemaName
)
- case .serverDashboard:
- tabManager.addServerDashboardTab()
- case .usersRoles:
- tabManager.addUsersRolesTab()
- case .insights:
- tabManager.addQueryInsightsTab()
- case .tableHealth:
- tabManager.addTableHealthTab()
+ case .serverDashboard, .usersRoles, .insights, .tableHealth:
+ tabManager.addSingletonTab(payload.tabType)
case .objectSource:
guard let objectRef = payload.objectRef else { return }
tabManager.addObjectSourceTab(objectRef: objectRef)
diff --git a/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift b/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift
index 5f123e2895..3b7c6f65d7 100644
--- a/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift
+++ b/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift
@@ -122,6 +122,7 @@ struct MenuValidationContext: Equatable {
var supportsRestore = false
var supportsServerDashboard = false
var supportsUserManagement = false
+ var supportsTableHealth = false
var supportsSchemaSwitching = false
// Same rule as the editor bar, so the menu cannot run a statement the bar's button refuses.
var supportsExplain = false
@@ -285,8 +286,10 @@ extension MainSplitViewController: NSMenuItemValidation {
return context.isConnected && context.supportsServerDashboard
case #selector(showUsersAndRoles(_:)):
return context.isConnected && context.supportsUserManagement
- case #selector(showQueryInsights(_:)), #selector(showTableHealth(_:)):
+ case #selector(showQueryInsights(_:)):
return context.isConnected
+ case #selector(showTableHealth(_:)):
+ return context.isConnected && context.supportsTableHealth
case #selector(toggleFilterBar(_:)):
return context.isConnected && context.canUseTableResultCommands
@@ -462,6 +465,9 @@ extension MainSplitViewController: NSMenuItemValidation {
#selector(importData(_:)),
#selector(importDataFormat(_:)),
#selector(showServerDashboard(_:)),
+ #selector(showUsersAndRoles(_:)),
+ #selector(showQueryInsights(_:)),
+ #selector(showTableHealth(_:)),
#selector(navigateBack(_:)),
#selector(navigateForward(_:)),
#selector(moveTabToConnection(_:)),
@@ -664,6 +670,7 @@ extension MainSplitViewController: NSMenuItemValidation {
supportsRestore: actions.supportsRestore,
supportsServerDashboard: actions.supportsServerDashboard,
supportsUserManagement: actions.supportsUserManagement,
+ supportsTableHealth: actions.supportsTableHealth,
supportsSchemaSwitching: actions.supportsSchemaSwitching,
supportsExplain: actions.supportsExplain,
supportsFormatting: actions.supportsFormatting,
diff --git a/apple/TablePro/Core/Services/Infrastructure/WindowTitleResolver.swift b/apple/TablePro/Core/Services/Infrastructure/WindowTitleResolver.swift
index 06d0bc89a0..0b57548852 100644
--- a/apple/TablePro/Core/Services/Infrastructure/WindowTitleResolver.swift
+++ b/apple/TablePro/Core/Services/Infrastructure/WindowTitleResolver.swift
@@ -91,6 +91,18 @@ enum WindowTitleResolver {
return ResolvedWindowTitle(title: sessionTitle, subtitle: "")
}
+ static func title(for tabType: TabType) -> String {
+ resolveTitle(
+ tabType: tabType,
+ tableName: nil,
+ schemaName: nil,
+ explicitTitle: nil,
+ sourceFileURL: nil,
+ databaseType: nil,
+ queryLanguageName: nil
+ )
+ }
+
static func resolveTitle(
payload: EditorTabPayload?,
databaseType: DatabaseType?,
diff --git a/apple/TablePro/Core/TableHealth/MySQLTableHealthProvider.swift b/apple/TablePro/Core/TableHealth/MySQLTableHealthProvider.swift
index 90ef630890..51be58970b 100644
--- a/apple/TablePro/Core/TableHealth/MySQLTableHealthProvider.swift
+++ b/apple/TablePro/Core/TableHealth/MySQLTableHealthProvider.swift
@@ -1,169 +1,194 @@
-//
-// MySQLTableHealthProvider.swift
-// TablePro
-//
-
import Foundation
-/// Reads every user schema on the server. InnoDB indexes a foreign key's columns itself, so there
-/// is no unindexed foreign key check. `scripts/probes/check-table-health-catalogs.sh` runs these
-/// queries against a real server, so keep each one a plain literal with no interpolation.
+// InnoDB indexes a foreign key's columns itself, so there is no unindexed foreign key check.
+// scripts/probes/check-table-health-catalogs.sh runs these literals against real servers: keep them free of interpolation.
struct MySQLTableHealthProvider: TableHealthProvider {
let checks: [TableHealthCheckKind] = [.unusedIndex, .redundantIndex, .missingPrimaryKey]
- let schemaNamesDatabase = true
- // The table sys.schema_unused_indexes reads, queried directly since MariaDB before 10.6 has no sys
- // schema. A unique index enforces something even when no query reads it.
+ static let performanceSchemaQuery = "SELECT @@GLOBAL.performance_schema AS performance_schema"
+
+ static let uptimeQuery = "SHOW GLOBAL STATUS LIKE 'Uptime'"
+
+ // Each of these stops index reads being counted with Performance Schema on: the handler
+ // instrument, the global consumer, or setup_objects and setup_actors leaving this database out.
+ static let usageCountingQuery = """
+ SELECT (SELECT i.ENABLED FROM performance_schema.setup_instruments i
+ WHERE i.NAME = 'wait/io/table/sql/handler') AS table_io_instrument,
+ (SELECT c.ENABLED FROM performance_schema.setup_consumers c
+ WHERE c.NAME = 'global_instrumentation') AS global_instrumentation,
+ (SELECT COUNT(*) FROM performance_schema.table_io_waits_summary_by_table t
+ WHERE t.OBJECT_SCHEMA = DATABASE() AND t.COUNT_STAR > 0) AS counted_tables
+ """
+
+ // The table sys.schema_unused_indexes reads; MariaDB before 10.6 has no sys schema.
static let unusedIndexQuery = """
- SELECT u.OBJECT_SCHEMA AS schema_name,
- u.OBJECT_NAME AS table_name,
+ SELECT u.OBJECT_NAME AS table_name,
u.INDEX_NAME AS index_name
FROM performance_schema.table_io_waits_summary_by_index_usage u
- WHERE u.INDEX_NAME IS NOT NULL
+ WHERE u.OBJECT_SCHEMA = DATABASE()
+ AND u.INDEX_NAME IS NOT NULL
AND u.INDEX_NAME <> 'PRIMARY'
AND u.COUNT_STAR = 0
- AND u.OBJECT_SCHEMA NOT IN ('mysql', 'sys', 'performance_schema', 'information_schema')
- AND NOT EXISTS (
- SELECT 1 FROM information_schema.STATISTICS s
- WHERE s.TABLE_SCHEMA = u.OBJECT_SCHEMA
- AND s.TABLE_NAME = u.OBJECT_NAME
- AND s.INDEX_NAME = u.INDEX_NAME
- AND s.NON_UNIQUE = 0
- )
- ORDER BY u.OBJECT_SCHEMA, u.OBJECT_NAME, u.INDEX_NAME
- LIMIT 500
+ ORDER BY u.OBJECT_NAME, u.INDEX_NAME
+ """
+
+ // Every column, so MySQL 8's IS_VISIBLE and MariaDB 10.6's IGNORED come back where the server has them.
+ static let indexQuery = """
+ SELECT *
+ FROM information_schema.STATISTICS
+ WHERE TABLE_SCHEMA = DATABASE()
+ ORDER BY TABLE_NAME, INDEX_NAME, SEQ_IN_INDEX
"""
- // The prefix comparison sys.schema_redundant_indexes makes, done in Swift so it needs no sys schema.
- static let redundantIndexQuery = """
- SELECT s.TABLE_SCHEMA AS schema_name,
- s.TABLE_NAME AS table_name,
- s.INDEX_NAME AS index_name,
- s.NON_UNIQUE AS non_unique,
- s.INDEX_TYPE AS index_type,
- s.COLUMN_NAME AS column_name,
- s.SUB_PART AS sub_part,
- s.COLLATION AS sort_order
- FROM information_schema.STATISTICS s
- WHERE s.TABLE_SCHEMA NOT IN ('mysql', 'sys', 'performance_schema', 'information_schema')
- ORDER BY s.TABLE_SCHEMA, s.TABLE_NAME, s.INDEX_NAME, s.SEQ_IN_INDEX
+ static let foreignKeyQuery = """
+ SELECT k.TABLE_NAME AS table_name,
+ k.CONSTRAINT_NAME AS constraint_name,
+ k.COLUMN_NAME AS column_name
+ FROM information_schema.KEY_COLUMN_USAGE k
+ WHERE k.TABLE_SCHEMA = DATABASE()
+ AND k.REFERENCED_TABLE_NAME IS NOT NULL
+ ORDER BY k.TABLE_NAME, k.CONSTRAINT_NAME, k.ORDINAL_POSITION
"""
+ // MariaDB lists a system-versioned table under its own type.
static let missingPrimaryKeyQuery = """
- SELECT t.TABLE_SCHEMA AS schema_name,
- t.TABLE_NAME AS table_name
+ SELECT t.TABLE_NAME AS table_name
FROM information_schema.TABLES t
- WHERE t.TABLE_TYPE = 'BASE TABLE'
- AND t.TABLE_SCHEMA NOT IN ('mysql', 'sys', 'performance_schema', 'information_schema')
+ WHERE t.TABLE_SCHEMA = DATABASE()
+ AND t.TABLE_TYPE IN ('BASE TABLE', 'SYSTEM VERSIONED')
AND NOT EXISTS (
SELECT 1 FROM information_schema.TABLE_CONSTRAINTS c
WHERE c.TABLE_SCHEMA = t.TABLE_SCHEMA
AND c.TABLE_NAME = t.TABLE_NAME
AND c.CONSTRAINT_TYPE = 'PRIMARY KEY'
)
- ORDER BY t.TABLE_SCHEMA, t.TABLE_NAME
- LIMIT 500
+ ORDER BY t.TABLE_NAME
"""
- static let performanceSchemaQuery = "SELECT @@GLOBAL.performance_schema AS performance_schema"
-
- static let uptimeQuery = "SHOW GLOBAL STATUS LIKE 'Uptime'"
+ var statisticsQueries: [String] {
+ [Self.performanceSchemaQuery, Self.uptimeQuery, Self.usageCountingQuery]
+ }
- var statisticsQueries: [String] { [Self.performanceSchemaQuery, Self.uptimeQuery] }
+ func queries(for check: TableHealthCheckKind) -> [String] {
+ switch check {
+ case .unusedIndex: return [Self.unusedIndexQuery, Self.indexQuery, Self.foreignKeyQuery]
+ case .redundantIndex: return [Self.indexQuery]
+ case .missingPrimaryKey: return [Self.missingPrimaryKeyQuery]
+ case .bloat, .unindexedForeignKey: return []
+ }
+ }
- var databaseQuery: String? { nil }
+ func statistics(from results: [QueryResult?], now: Date) -> TableHealthStatistics {
+ func value(_ position: Int, _ column: String) -> String? {
+ guard position < results.count, let result = results[position] else { return nil }
+ return TableHealthRow.rows(of: result).first?[column]
+ }
+ func skippingUnused(_ reason: String) -> TableHealthStatistics {
+ TableHealthStatistics(skippedChecks: [.unusedIndex: reason])
+ }
- func query(for check: TableHealthCheckKind) -> String {
- switch check {
- case .unusedIndex: return Self.unusedIndexQuery
- case .redundantIndex: return Self.redundantIndexQuery
- case .missingPrimaryKey: return Self.missingPrimaryKeyQuery
- case .bloat, .unindexedForeignKey: return ""
+ if let enabled = value(0, "performance_schema"), enabled != "1", enabled.uppercased() != "ON" {
+ return skippingUnused(String(localized: "Performance Schema is off, so the server records no index reads."))
}
+ if value(2, "table_io_instrument") == "NO" {
+ return skippingUnused(String(localized: "The wait/io/table/sql/handler instrument is disabled, so the server records no index reads."))
+ }
+ if value(2, "global_instrumentation") == "NO" {
+ return skippingUnused(String(localized: "The global_instrumentation consumer is disabled, so the server records no index reads."))
+ }
+ if value(2, "counted_tables") == "0" {
+ return skippingUnused(String(
+ localized: "No table in this database has a recorded read or write since the server started, so an unread index cannot be told from an uncounted one."
+ ))
+ }
+
+ guard let uptime = value(1, "value").flatMap(Double.init) else { return TableHealthStatistics() }
+ let start = now.addingTimeInterval(-uptime).formatted(date: .abbreviated, time: .shortened)
+ return TableHealthStatistics(
+ note: String(format: String(localized: "Index usage counts since the server started at %@."), start)
+ )
}
- func findings(for check: TableHealthCheckKind, from result: QueryResult) -> [TableHealthFinding] {
- let rows = TableHealthRow.rows(of: result)
+ func findings(
+ for check: TableHealthCheckKind,
+ from results: [QueryResult],
+ context: TableHealthQueryContext
+ ) -> [TableHealthFinding] {
switch check {
case .unusedIndex:
- return rows.map(unusedIndexFinding)
+ guard results.count == 3 else { return [] }
+ return unusedIndexFindings(
+ candidates: TableHealthRow.rows(of: results[0]),
+ tables: MySQLIndexShape.tables(from: TableHealthRow.rows(of: results[1])),
+ foreignKeys: MySQLIndexShape.foreignKeys(from: TableHealthRow.rows(of: results[2])),
+ context: context
+ )
case .redundantIndex:
- return redundantIndexFindings(rows)
+ guard let result = results.first else { return [] }
+ return redundantIndexFindings(MySQLIndexShape.tables(from: TableHealthRow.rows(of: result)), context)
case .missingPrimaryKey:
- return rows.map(missingPrimaryKeyFinding)
+ guard let result = results.first else { return [] }
+ return TableHealthRow.rows(of: result).map { missingPrimaryKeyFinding($0["table_name"], context) }
case .bloat, .unindexedForeignKey:
return []
}
}
- /// Performance Schema counts index reads from the server start, and records none while it is off.
- func statisticsNote(from results: [QueryResult], now: Date) -> String? {
- guard results.count == 2 else { return nil }
- let enabled = TableHealthRow.rows(of: results[0]).first?["performance_schema"] ?? ""
- guard enabled == "1" || enabled.uppercased() == "ON" else {
- return String(localized: "Performance Schema is off, so the server records no index usage and no index can show as unused.")
+ // A unique index enforces something no read counts. InnoDB checks a foreign key through the
+ // index that leads with its columns without counting a read, and refuses to drop the last one.
+ private func unusedIndexFindings(
+ candidates: [TableHealthRow],
+ tables: [MySQLIndexShape],
+ foreignKeys: [String: [[String]]],
+ context: TableHealthQueryContext
+ ) -> [TableHealthFinding] {
+ let shapes = Dictionary(tables.map { ($0.name, $0) }, uniquingKeysWith: { first, _ in first })
+ return candidates.compactMap { row in
+ let table = row["table_name"], indexName = row["index_name"]
+ guard let index = shapes[table]?.indexes.first(where: { $0.name == indexName }),
+ !index.isUnique,
+ !(foreignKeys[table] ?? []).contains(where: index.serves)
+ else { return nil }
+ return TableHealthFinding(
+ kind: .unusedIndex, schema: nil, table: table, objectName: indexName,
+ detail: String(format: String(localized: "%@ has no recorded reads."), indexName),
+ fixSQL: dropIndexSQL(table: table, index: indexName, context)
+ )
}
- guard let uptime = TableHealthRow.rows(of: results[1]).first.flatMap({ Double($0["value"]) }) else { return nil }
- let start = now.addingTimeInterval(-uptime).formatted(date: .abbreviated, time: .shortened)
- return String(format: String(localized: "Index usage counts since the server started at %@."), start)
- }
-
- // MARK: - Fix SQL
-
- static func dropIndexSQL(schema: String, table: String, index: String) -> String {
- "ALTER TABLE \(qualified(schema, table)) DROP INDEX \(quote(index));"
- }
-
- static func addPrimaryKeySQL(schema: String, table: String) -> String {
- "ALTER TABLE \(qualified(schema, table)) ADD PRIMARY KEY (column_name);"
- }
-
- static func quote(_ identifier: String) -> String {
- "`" + identifier.replacingOccurrences(of: "`", with: "``") + "`"
- }
-
- private static func qualified(_ schema: String, _ name: String) -> String {
- "\(quote(schema)).\(quote(name))"
- }
-
- // MARK: - Parsing
-
- private func unusedIndexFinding(_ row: TableHealthRow) -> TableHealthFinding {
- let schema = row["schema_name"], table = row["table_name"], index = row["index_name"]
- return TableHealthFinding(
- kind: .unusedIndex, schema: schema, table: table, objectName: index,
- detail: String(format: String(localized: "%@ has no recorded reads."), index),
- fixSQL: Self.dropIndexSQL(schema: schema, table: table, index: index)
- )
}
- private func redundantIndexFindings(_ rows: [TableHealthRow]) -> [TableHealthFinding] {
+ private func redundantIndexFindings(
+ _ tables: [MySQLIndexShape],
+ _ context: TableHealthQueryContext
+ ) -> [TableHealthFinding] {
var findings: [TableHealthFinding] = []
- for table in MySQLIndexShape.tables(from: rows) {
+ for table in tables {
for index in table.indexes {
guard let dominant = table.indexes.first(where: { index.isRedundant(to: $0) }) else { continue }
findings.append(TableHealthFinding(
- kind: .redundantIndex, schema: table.schema, table: table.name, objectName: index.name,
+ kind: .redundantIndex, schema: nil, table: table.name, objectName: index.name,
detail: String(format: String(localized: "%1$@ is covered by %2$@."), index.name, dominant.name),
- fixSQL: Self.dropIndexSQL(schema: table.schema, table: table.name, index: index.name)
+ fixSQL: dropIndexSQL(table: table.name, index: index.name, context)
))
- if findings.count == TableHealthCheckResult.findingLimit { return findings }
}
}
return findings
}
- private func missingPrimaryKeyFinding(_ row: TableHealthRow) -> TableHealthFinding {
- let schema = row["schema_name"], table = row["table_name"]
- return TableHealthFinding(
- kind: .missingPrimaryKey, schema: schema, table: table, objectName: nil,
+ private func missingPrimaryKeyFinding(_ table: String, _ context: TableHealthQueryContext) -> TableHealthFinding {
+ TableHealthFinding(
+ kind: .missingPrimaryKey, schema: nil, table: table, objectName: nil,
detail: String(localized: "No primary key. Replace column_name with the columns that identify a row."),
- fixSQL: Self.addPrimaryKeySQL(schema: schema, table: table)
+ fixSQL: "ALTER TABLE \(context.qualified(context.database, table)) ADD PRIMARY KEY (column_name);"
)
}
+
+ private func dropIndexSQL(table: String, index: String, _ context: TableHealthQueryContext) -> String {
+ "ALTER TABLE \(context.qualified(context.database, table)) DROP INDEX \(context.quote(index));"
+ }
}
-/// The indexes of one table, read from `information_schema.STATISTICS` rows ordered by index and position.
+// The indexes of one table, from information_schema.STATISTICS rows ordered by table, index and position.
struct MySQLIndexShape {
struct Column: Equatable {
let name: String
@@ -175,18 +200,18 @@ struct MySQLIndexShape {
let name: String
let isUnique: Bool
let type: String
+ // An invisible (MySQL) or ignored (MariaDB) index serves no query.
+ let isVisible: Bool
var columns: [Column]
- /// A functional key part has no column name and cannot be compared by prefix.
+ // A functional key part has no column name and cannot be compared by prefix.
var hasExpression: Bool
var isPrimary: Bool { name == "PRIMARY" }
- /// Redundant when another index of the same type starts with the same columns in the same
- /// directions. Only a B-tree serves a lookup on its leading columns, so any other type is
- /// redundant only to an identical index. Of two equal indexes, the unique one stays; of two
- /// equal unique or two equal plain ones, the first by name stays.
+ // Only a B-tree serves a lookup on its leading columns, so another type is redundant only
+ // to an identical index. Of two equal indexes the unique one stays, else the first by name.
func isRedundant(to other: Index) -> Bool {
- guard other.name != name, !isPrimary, !hasExpression, !other.hasExpression,
+ guard other.name != name, other.isVisible, !isPrimary, !hasExpression, !other.hasExpression,
other.type == type, columns.count <= other.columns.count,
type == "BTREE" || columns.count == other.columns.count,
Array(other.columns.prefix(columns.count)) == columns
@@ -195,38 +220,65 @@ struct MySQLIndexShape {
if isUnique != other.isUnique { return other.isUnique }
return other.isPrimary || other.name < name
}
+
+ // InnoDB needs the key's columns first, in order, and whole.
+ func serves(foreignKey keyColumns: [String]) -> Bool {
+ guard !hasExpression, columns.count >= keyColumns.count else { return false }
+ return zip(columns, keyColumns).allSatisfy { column, key in
+ column.prefixLength.isEmpty && column.name.lowercased() == key.lowercased()
+ }
+ }
}
- let schema: String
let name: String
var indexes: [Index]
static func tables(from rows: [TableHealthRow]) -> [MySQLIndexShape] {
var tables: [MySQLIndexShape] = []
for row in rows {
- let schema = row["schema_name"], table = row["table_name"], indexName = row["index_name"]
- if tables.last?.schema != schema || tables.last?.name != table {
- tables.append(MySQLIndexShape(schema: schema, name: table, indexes: []))
+ let table = row["table_name"], indexName = row["index_name"]
+ if tables.last?.name != table {
+ tables.append(MySQLIndexShape(name: table, indexes: []))
}
- let column = row["column_name"]
- if tables[tables.count - 1].indexes.last?.name != indexName {
- tables[tables.count - 1].indexes.append(Index(
+ let tableIndex = tables.count - 1
+ if tables[tableIndex].indexes.last?.name != indexName {
+ tables[tableIndex].indexes.append(Index(
name: indexName,
isUnique: row["non_unique"] == "0",
type: row["index_type"],
+ isVisible: row["is_visible"] != "NO" && row["ignored"] != "YES",
columns: [],
hasExpression: false
))
}
- let last = tables[tables.count - 1].indexes.count - 1
+ let last = tables[tableIndex].indexes.count - 1
+ let column = row["column_name"]
if column.isEmpty {
- tables[tables.count - 1].indexes[last].hasExpression = true
+ tables[tableIndex].indexes[last].hasExpression = true
} else {
- tables[tables.count - 1].indexes[last].columns.append(
- Column(name: column, prefixLength: row["sub_part"], sortOrder: row["sort_order"])
+ tables[tableIndex].indexes[last].columns.append(
+ Column(name: column, prefixLength: row["sub_part"], sortOrder: row["collation"])
)
}
}
return tables
}
+
+ // Each table's foreign keys, as their columns in key order.
+ static func foreignKeys(from rows: [TableHealthRow]) -> [String: [[String]]] {
+ var keys: [String: [[String]]] = [:]
+ var previous: (table: String, constraint: String)?
+ for row in rows {
+ let table = row["table_name"], constraint = row["constraint_name"]
+ var tableKeys = keys[table] ?? []
+ if previous?.table == table, previous?.constraint == constraint, !tableKeys.isEmpty {
+ tableKeys[tableKeys.count - 1].append(row["column_name"])
+ } else {
+ tableKeys.append([row["column_name"]])
+ }
+ keys[table] = tableKeys
+ previous = (table, constraint)
+ }
+ return keys
+ }
}
diff --git a/apple/TablePro/Core/TableHealth/PostgreSQLTableHealthProvider.swift b/apple/TablePro/Core/TableHealth/PostgreSQLTableHealthProvider.swift
index bcc49da76f..5d2081440f 100644
--- a/apple/TablePro/Core/TableHealth/PostgreSQLTableHealthProvider.swift
+++ b/apple/TablePro/Core/TableHealth/PostgreSQLTableHealthProvider.swift
@@ -1,31 +1,37 @@
-//
-// PostgreSQLTableHealthProvider.swift
-// TablePro
-//
-
import Foundation
-/// Reads the current database. `scripts/probes/check-table-health-catalogs.sh` runs these queries
-/// against a real server, so keep each one a plain literal with no interpolation.
+// scripts/probes/check-table-health-catalogs.sh runs these literals against real servers: keep them free of interpolation.
struct PostgreSQLTableHealthProvider: TableHealthProvider {
let checks: [TableHealthCheckKind] = [.bloat, .unusedIndex, .missingPrimaryKey, .unindexedForeignKey]
- let schemaNamesDatabase = false
- // Autovacuum's own trigger is 20% dead rows plus 50; the floor keeps tiny tables out.
+ static let statisticsQuery = """
+ SELECT pg_catalog.current_setting('track_counts') AS track_counts,
+ pg_catalog.date_trunc('second', d.stats_reset)::pg_catalog.text AS stats_reset
+ FROM pg_catalog.pg_stat_database d
+ WHERE d.datname = pg_catalog.current_database()
+ """
+
+ // Autovacuum's own trigger is 20% dead rows plus 50; the floor keeps tiny tables out. A pg_
+ // schema is another session's temporary one.
static let bloatQuery = """
- SELECT schemaname AS schema_name,
- relname AS table_name,
- n_live_tup AS live_rows,
- n_dead_tup AS dead_rows,
- pg_catalog.date_trunc('second', GREATEST(last_vacuum, last_autovacuum))::pg_catalog.text AS last_vacuum
- FROM pg_catalog.pg_stat_user_tables
- WHERE n_dead_tup >= 1000 AND n_dead_tup >= n_live_tup / 5
- ORDER BY n_dead_tup DESC, schemaname, relname
- LIMIT 500
+ SELECT s.schemaname AS schema_name,
+ s.relname AS table_name,
+ s.n_live_tup AS live_rows,
+ s.n_dead_tup AS dead_rows,
+ pg_catalog.date_trunc('second', GREATEST(s.last_vacuum, s.last_autovacuum))::pg_catalog.text AS last_vacuum
+ FROM pg_catalog.pg_stat_user_tables s
+ JOIN pg_catalog.pg_class c ON c.oid = s.relid
+ WHERE c.relkind = 'r'
+ AND pg_catalog.left(s.schemaname, 3) <> 'pg_'
+ AND s.n_dead_tup >= 1000
+ AND s.n_dead_tup >= s.n_live_tup / 5
+ ORDER BY s.n_dead_tup DESC, s.schemaname, s.relname
"""
- // A unique or constraint index enforces something even when no query reads it. An index on a
- // partition that a parent index owns cannot be dropped on its own.
+ // A unique or constraint index enforces something no scan counts, an index a foreign key's
+ // checks use is needed the first time the referenced table deletes, and a partition's index
+ // that a parent index owns cannot be dropped alone. The foreign key test is the one
+ // unindexedForeignKeyQuery uses.
static let unusedIndexQuery = """
SELECT s.schemaname AS schema_name,
s.relname AS table_name,
@@ -33,14 +39,29 @@ struct PostgreSQLTableHealthProvider: TableHealthProvider {
pg_catalog.pg_relation_size(s.indexrelid) AS index_bytes
FROM pg_catalog.pg_stat_user_indexes s
JOIN pg_catalog.pg_index i ON i.indexrelid = s.indexrelid
+ JOIN pg_catalog.pg_class t ON t.oid = s.relid
WHERE s.idx_scan = 0
+ AND t.relkind = 'r'
+ AND pg_catalog.left(s.schemaname, 3) <> 'pg_'
AND NOT i.indisunique
AND NOT EXISTS (SELECT 1 FROM pg_catalog.pg_constraint c WHERE c.conindid = s.indexrelid)
AND NOT EXISTS (SELECT 1 FROM pg_catalog.pg_inherits h WHERE h.inhrelid = s.indexrelid)
+ AND NOT (
+ i.indpred IS NULL
+ AND i.indisvalid
+ AND EXISTS (
+ SELECT 1 FROM pg_catalog.pg_constraint fk
+ WHERE fk.contype = 'f'
+ AND fk.conrelid = i.indrelid
+ AND pg_catalog.array_length(fk.conkey, 1)
+ <= COALESCE((pg_catalog.row_to_json(i) ->> 'indnkeyatts')::pg_catalog.int4, i.indnatts)
+ AND (i.indkey::pg_catalog.int2[])[0:pg_catalog.array_length(fk.conkey, 1) - 1] @> fk.conkey
+ )
+ )
ORDER BY index_bytes DESC, schema_name, index_name
- LIMIT 500
"""
+ // A partition takes its primary key from its parent, so only the parent is listed.
static let missingPrimaryKeyQuery = """
SELECT n.nspname AS schema_name,
c.relname AS table_name
@@ -53,17 +74,22 @@ struct PostgreSQLTableHealthProvider: TableHealthProvider {
SELECT 1 FROM pg_catalog.pg_constraint con
WHERE con.conrelid = c.oid AND con.contype = 'p'
)
+ AND NOT EXISTS (
+ SELECT 1 FROM pg_catalog.pg_inherits h
+ JOIN pg_catalog.pg_class p ON p.oid = h.inhparent
+ WHERE h.inhrelid = c.oid AND p.relkind = 'p'
+ )
ORDER BY n.nspname, c.relname
- LIMIT 500
"""
// One row per referencing column. An index covers the key when its leading key columns are the
- // key's columns in any order. A partial or invalid index does not, and neither does an INCLUDE
- // column, which is stored but not searchable; indnkeyatts is read through row_to_json because
- // servers before 11 have no such column.
+ // key's columns in any order; a partial or invalid index does not, nor does an INCLUDE column.
+ // A partition's clone of its parent's key (conparentid) is left to the parent's finding.
+ // indnkeyatts and conparentid go through row_to_json because servers before 11 lack them.
static let unindexedForeignKeyQuery = """
SELECT n.nspname AS schema_name,
c.relname AS table_name,
+ c.relkind AS table_kind,
con.conname AS constraint_name,
a.attname AS column_name
FROM pg_catalog.pg_constraint con
@@ -72,6 +98,8 @@ struct PostgreSQLTableHealthProvider: TableHealthProvider {
CROSS JOIN LATERAL pg_catalog.unnest(con.conkey) WITH ORDINALITY AS k(attnum, position)
JOIN pg_catalog.pg_attribute a ON a.attrelid = con.conrelid AND a.attnum = k.attnum
WHERE con.contype = 'f'
+ AND pg_catalog.left(n.nspname, 3) <> 'pg_'
+ AND COALESCE(pg_catalog.row_to_json(con) ->> 'conparentid', '0') = '0'
AND NOT EXISTS (
SELECT 1 FROM pg_catalog.pg_index i
WHERE i.indrelid = con.conrelid
@@ -84,79 +112,60 @@ struct PostgreSQLTableHealthProvider: TableHealthProvider {
ORDER BY n.nspname, c.relname, con.conname, k.position
"""
- static let statisticsResetQuery = """
- SELECT pg_catalog.date_trunc('second', stats_reset)::pg_catalog.text AS stats_reset
- FROM pg_catalog.pg_stat_database
- WHERE datname = pg_catalog.current_database()
- """
-
- static let databaseNameQuery = "SELECT pg_catalog.current_database() AS database_name"
+ var statisticsQueries: [String] { [Self.statisticsQuery] }
- var statisticsQueries: [String] { [Self.statisticsResetQuery] }
+ func queries(for check: TableHealthCheckKind) -> [String] {
+ switch check {
+ case .bloat: return [Self.bloatQuery]
+ case .unusedIndex: return [Self.unusedIndexQuery]
+ case .missingPrimaryKey: return [Self.missingPrimaryKeyQuery]
+ case .unindexedForeignKey: return [Self.unindexedForeignKeyQuery]
+ case .redundantIndex: return []
+ }
+ }
- var databaseQuery: String? { Self.databaseNameQuery }
+ func statistics(from results: [QueryResult?], now: Date) -> TableHealthStatistics {
+ guard let result = results.first.flatMap({ $0 }),
+ let row = TableHealthRow.rows(of: result).first
+ else { return TableHealthStatistics() }
- func query(for check: TableHealthCheckKind) -> String {
- switch check {
- case .bloat: return Self.bloatQuery
- case .unusedIndex: return Self.unusedIndexQuery
- case .missingPrimaryKey: return Self.missingPrimaryKeyQuery
- case .unindexedForeignKey: return Self.unindexedForeignKeyQuery
- case .redundantIndex: return ""
+ if row["track_counts"] == "off" {
+ let reason = String(localized: "track_counts is off, so the server records no table or index activity.")
+ return TableHealthStatistics(skippedChecks: [.bloat: reason, .unusedIndex: reason])
+ }
+ let reset = row["stats_reset"]
+ guard !reset.isEmpty else {
+ return TableHealthStatistics(
+ note: String(localized: "Index usage counts since the statistics were first collected; they have never been reset.")
+ )
}
+ return TableHealthStatistics(
+ note: String(format: String(localized: "Index usage counts since the statistics were last reset at %@."), reset)
+ )
}
- func findings(for check: TableHealthCheckKind, from result: QueryResult) -> [TableHealthFinding] {
+ func findings(
+ for check: TableHealthCheckKind,
+ from results: [QueryResult],
+ context: TableHealthQueryContext
+ ) -> [TableHealthFinding] {
+ guard let result = results.first else { return [] }
let rows = TableHealthRow.rows(of: result)
switch check {
case .bloat:
- return rows.map(bloatFinding)
+ return rows.map { bloatFinding($0, context) }
case .unusedIndex:
- return rows.map(unusedIndexFinding)
+ return rows.map { unusedIndexFinding($0, context) }
case .missingPrimaryKey:
- return rows.map(missingPrimaryKeyFinding)
+ return rows.map { missingPrimaryKeyFinding($0, context) }
case .unindexedForeignKey:
- return unindexedForeignKeyFindings(rows)
+ return unindexedForeignKeyFindings(rows, context)
case .redundantIndex:
return []
}
}
- func statisticsNote(from results: [QueryResult], now: Date) -> String? {
- guard let result = results.first, let row = TableHealthRow.rows(of: result).first else { return nil }
- let reset = row["stats_reset"]
- guard !reset.isEmpty else {
- return String(localized: "Index usage counts since the statistics were first collected; they have never been reset.")
- }
- return String(format: String(localized: "Index usage counts since the statistics were last reset at %@."), reset)
- }
-
- // MARK: - Fix SQL
-
- static func vacuumSQL(schema: String, table: String) -> String {
- "VACUUM (ANALYZE) \(qualified(schema, table));"
- }
-
- static func dropIndexSQL(schema: String, index: String) -> String {
- "DROP INDEX CONCURRENTLY \(qualified(schema, index));"
- }
-
- static func addPrimaryKeySQL(schema: String, table: String) -> String {
- "ALTER TABLE \(qualified(schema, table)) ADD PRIMARY KEY (column_name);"
- }
-
- static func createIndexSQL(schema: String, table: String, columns: [String]) -> String {
- let columnList = columns.map(SQLEscaping.quoteIdentifier).joined(separator: ", ")
- return "CREATE INDEX CONCURRENTLY ON \(qualified(schema, table)) (\(columnList));"
- }
-
- private static func qualified(_ schema: String, _ name: String) -> String {
- "\(SQLEscaping.quoteIdentifier(schema)).\(SQLEscaping.quoteIdentifier(name))"
- }
-
- // MARK: - Parsing
-
- private func bloatFinding(_ row: TableHealthRow) -> TableHealthFinding {
+ private func bloatFinding(_ row: TableHealthRow, _ context: TableHealthQueryContext) -> TableHealthFinding {
let schema = row["schema_name"], table = row["table_name"]
let dead = TableHealthFormatting.count(row["dead_rows"])
let live = TableHealthFormatting.count(row["live_rows"])
@@ -165,43 +174,58 @@ struct PostgreSQLTableHealthProvider: TableHealthProvider {
? String(format: String(localized: "%1$@ dead rows, %2$@ live. Never vacuumed."), dead, live)
: String(format: String(localized: "%1$@ dead rows, %2$@ live. Last vacuumed %3$@."), dead, live, lastVacuum)
return TableHealthFinding(
- kind: .bloat, schema: schema, table: table, objectName: nil,
- detail: detail, fixSQL: Self.vacuumSQL(schema: schema, table: table)
+ kind: .bloat, schema: schema, table: table, objectName: nil, detail: detail,
+ fixSQL: "VACUUM (ANALYZE) \(context.qualified(schema, table));"
)
}
- private func unusedIndexFinding(_ row: TableHealthRow) -> TableHealthFinding {
+ private func unusedIndexFinding(_ row: TableHealthRow, _ context: TableHealthQueryContext) -> TableHealthFinding {
let schema = row["schema_name"], index = row["index_name"]
let size = ByteSizeFormatting.string(byteString: row["index_bytes"])
return TableHealthFinding(
kind: .unusedIndex, schema: schema, table: row["table_name"], objectName: index,
detail: String(format: String(localized: "%1$@ has never been scanned. It takes %2$@."), index, size),
- fixSQL: Self.dropIndexSQL(schema: schema, index: index)
+ fixSQL: "DROP INDEX CONCURRENTLY \(context.qualified(schema, index));"
)
}
- private func missingPrimaryKeyFinding(_ row: TableHealthRow) -> TableHealthFinding {
+ private func missingPrimaryKeyFinding(_ row: TableHealthRow, _ context: TableHealthQueryContext) -> TableHealthFinding {
let schema = row["schema_name"], table = row["table_name"]
return TableHealthFinding(
kind: .missingPrimaryKey, schema: schema, table: table, objectName: nil,
detail: String(localized: "No primary key. Replace column_name with the columns that identify a row."),
- fixSQL: Self.addPrimaryKeySQL(schema: schema, table: table)
+ fixSQL: "ALTER TABLE \(context.qualified(schema, table)) ADD PRIMARY KEY (column_name);"
)
}
- private func unindexedForeignKeyFindings(_ rows: [TableHealthRow]) -> [TableHealthFinding] {
- var order: [String] = []
- var grouped: [String: (schema: String, table: String, constraint: String, columns: [String])] = [:]
+ private struct ForeignKey {
+ let schema: String
+ let table: String
+ let isPartitioned: Bool
+ let constraint: String
+ var columns: [String]
+ }
+
+ private func unindexedForeignKeyFindings(
+ _ rows: [TableHealthRow],
+ _ context: TableHealthQueryContext
+ ) -> [TableHealthFinding] {
+ var keys: [ForeignKey] = []
for row in rows {
- let key = [row["schema_name"], row["table_name"], row["constraint_name"]].joined(separator: "\u{1F}")
- if grouped[key] == nil {
- order.append(key)
- grouped[key] = (row["schema_name"], row["table_name"], row["constraint_name"], [])
+ let schema = row["schema_name"], table = row["table_name"], constraint = row["constraint_name"]
+ if let last = keys.last, last.schema == schema, last.table == table, last.constraint == constraint {
+ keys[keys.count - 1].columns.append(row["column_name"])
+ } else {
+ keys.append(ForeignKey(
+ schema: schema, table: table, isPartitioned: row["table_kind"] == "p",
+ constraint: constraint, columns: [row["column_name"]]
+ ))
}
- grouped[key]?.columns.append(row["column_name"])
}
- return order.prefix(TableHealthCheckResult.findingLimit).compactMap { key in
- guard let key = grouped[key] else { return nil }
+ return keys.map { key in
+ let columnList = key.columns.map(context.quote).joined(separator: ", ")
+ // PostgreSQL cannot build an index on a partitioned table concurrently.
+ let create = key.isPartitioned ? "CREATE INDEX" : "CREATE INDEX CONCURRENTLY"
return TableHealthFinding(
kind: .unindexedForeignKey, schema: key.schema, table: key.table, objectName: key.constraint,
detail: String(
@@ -209,15 +233,8 @@ struct PostgreSQLTableHealthProvider: TableHealthProvider {
key.constraint,
key.columns.joined(separator: ", ")
),
- fixSQL: Self.createIndexSQL(schema: key.schema, table: key.table, columns: key.columns)
+ fixSQL: "\(create) ON \(context.qualified(key.schema, key.table)) (\(columnList));"
)
}
}
}
-
-enum TableHealthFormatting {
- static func count(_ text: String) -> String {
- guard let value = Int64(text) else { return text }
- return value.formatted()
- }
-}
diff --git a/apple/TablePro/Core/TableHealth/TableHealthProvider.swift b/apple/TablePro/Core/TableHealth/TableHealthProvider.swift
index 33dfab3612..49342b60e2 100644
--- a/apple/TablePro/Core/TableHealth/TableHealthProvider.swift
+++ b/apple/TablePro/Core/TableHealth/TableHealthProvider.swift
@@ -1,86 +1,89 @@
-//
-// TableHealthProvider.swift
-// TablePro
-//
-
import Foundation
import TableProPluginKit
-/// Reads one engine's catalogs into table and index health findings. Every query only reads;
-/// the fixes it builds are text for the user to review.
+struct TableHealthQueryContext {
+ let database: String
+ let quote: (String) -> String
+
+ func qualified(_ parts: String...) -> String {
+ parts.map(quote).joined(separator: ".")
+ }
+}
+
protocol TableHealthProvider: Sendable {
var checks: [TableHealthCheckKind] { get }
- /// MySQL calls a database a schema, so a finding's schema is the database its table opens in.
- var schemaNamesDatabase: Bool { get }
var statisticsQueries: [String] { get }
- /// Names the database the catalogs describe, for an engine whose report covers one database only.
- var databaseQuery: String? { get }
- func query(for check: TableHealthCheckKind) -> String
- func findings(for check: TableHealthCheckKind, from result: QueryResult) -> [TableHealthFinding]
- func statisticsNote(from results: [QueryResult], now: Date) -> String?
+ func statistics(from results: [QueryResult?], now: Date) -> TableHealthStatistics
+ func queries(for check: TableHealthCheckKind) -> [String]
+ func findings(
+ for check: TableHealthCheckKind,
+ from results: [QueryResult],
+ context: TableHealthQueryContext
+ ) -> [TableHealthFinding]
}
extension TableHealthProvider {
- /// One failing check (a privilege, Performance Schema access) leaves the others standing.
- @MainActor
+ // One failing check, a missing privilege say, leaves the others standing.
func fetchReport(
- execute: @MainActor (String) async throws -> QueryResult,
- now: Date = Date()
+ context: TableHealthQueryContext,
+ now: Date = Date(),
+ execute: (String) async throws -> QueryResult
) async throws -> TableHealthReport {
- var databaseName: String?
- if let databaseQuery {
- let result = try await execute(databaseQuery)
- databaseName = TableHealthRow.rows(of: result).first?["database_name"]
- }
-
- var results: [TableHealthCheckResult] = []
- for check in checks {
+ var answered: [String: Result] = [:]
+ func run(_ query: String) async throws -> QueryResult {
+ if let answer = answered[query] { return try answer.get() }
+ try Task.checkCancellation()
do {
- let result = try await execute(query(for: check))
- results.append(TableHealthCheckResult(kind: check, findings: findings(for: check, from: result)))
- } catch is CancellationError {
- throw CancellationError()
+ let result = try await execute(query)
+ answered[query] = .success(result)
+ return result
+ } catch let error as CancellationError {
+ throw error
} catch {
- results.append(TableHealthCheckResult(kind: check, findings: [], error: error.localizedDescription))
+ answered[query] = .failure(error)
+ throw error
}
}
- var statisticsResults: [QueryResult] = []
- do {
- for query in statisticsQueries {
- statisticsResults.append(try await execute(query))
+ var statisticsResults: [QueryResult?] = []
+ for query in statisticsQueries {
+ do {
+ statisticsResults.append(try await run(query))
+ } catch let error as CancellationError {
+ throw error
+ } catch {
+ statisticsResults.append(nil)
}
- } catch is CancellationError {
- throw CancellationError()
- } catch {
- statisticsResults = []
}
- let note = statisticsResults.count == statisticsQueries.count
- ? statisticsNote(from: statisticsResults, now: now)
- : nil
+ let statistics = statistics(from: statisticsResults, now: now)
- // The connection can move to another database between queries; a report that spans two would
- // send one database's fixes to the other.
- if let databaseQuery {
- let result = try await execute(databaseQuery)
- let finalName = TableHealthRow.rows(of: result).first?["database_name"]
- guard finalName == databaseName else { throw TableHealthError.databaseChanged }
+ var results: [TableHealthCheckResult] = []
+ for check in checks {
+ if let reason = statistics.skippedChecks[check] {
+ results.append(TableHealthCheckResult(kind: check, findings: [], outcome: .skipped(reason)))
+ continue
+ }
+ do {
+ var inputs: [QueryResult] = []
+ for query in queries(for: check) {
+ inputs.append(try await run(query))
+ }
+ let found = findings(for: check, from: inputs, context: context)
+ results.append(TableHealthCheckResult(kind: check, findings: found, outcome: .completed))
+ } catch let error as CancellationError {
+ throw error
+ } catch {
+ results.append(TableHealthCheckResult(
+ kind: check, findings: [], outcome: .failed(error.localizedDescription)
+ ))
+ }
}
-
- return TableHealthReport(checks: results, databaseName: databaseName, statisticsNote: note)
- }
-}
-
-enum TableHealthError: LocalizedError {
- case databaseChanged
-
- var errorDescription: String? {
- String(localized: "The connection switched databases during the refresh. Refresh again.")
+ return TableHealthReport(database: context.database, checks: results, statisticsNote: statistics.note)
}
}
enum TableHealthProviderFactory {
- static func provider(for databaseType: DatabaseType) -> TableHealthProvider? {
+ static func provider(for databaseType: DatabaseType) -> (any TableHealthProvider)? {
switch databaseType {
case .postgresql:
return PostgreSQLTableHealthProvider()
@@ -92,7 +95,7 @@ enum TableHealthProviderFactory {
}
}
-/// A result row read by column name, so a reordered SELECT list cannot shift a value.
+// Read by column name, so a reordered SELECT list cannot shift a value.
struct TableHealthRow {
private let values: [PluginCellValue]
private let columns: [String: Int]
@@ -110,3 +113,10 @@ struct TableHealthRow {
return values[index].asText ?? ""
}
}
+
+enum TableHealthFormatting {
+ static func count(_ text: String) -> String {
+ guard let value = Int64(text) else { return text }
+ return value.formatted()
+ }
+}
diff --git a/apple/TablePro/Models/Query/QueryTabManager.swift b/apple/TablePro/Models/Query/QueryTabManager.swift
index b436ea0729..27fbb60c23 100644
--- a/apple/TablePro/Models/Query/QueryTabManager.swift
+++ b/apple/TablePro/Models/Query/QueryTabManager.swift
@@ -503,38 +503,13 @@ final class QueryTabManager: ObservableObject {
selectedTabId = newTab.id
}
- func addServerDashboardTab() {
- if let existing = tabs.first(where: { $0.tabType == .serverDashboard }) {
+ // Server Dashboard, Users & Roles, Query Insights and Table Health: one per connection.
+ func addSingletonTab(_ tabType: TabType) {
+ if let existing = tabs.first(where: { $0.tabType == tabType }) {
selectedTabId = existing.id
return
}
- let tabTitle = String(localized: "Server Dashboard")
- var newTab = QueryTab(title: tabTitle, tabType: .serverDashboard)
- newTab.tableContext.isEditable = false
- newTab.hasUserInteraction = true
- tabs.append(newTab)
- selectedTabId = newTab.id
- }
-
- func addQueryInsightsTab() {
- if let existing = tabs.first(where: { $0.tabType == .insights }) {
- selectedTabId = existing.id
- return
- }
- let tabTitle = String(localized: "Query Insights")
- var newTab = QueryTab(title: tabTitle, tabType: .insights)
- newTab.tableContext.isEditable = false
- newTab.hasUserInteraction = true
- tabs.append(newTab)
- selectedTabId = newTab.id
- }
-
- func addTableHealthTab() {
- if let existing = tabs.first(where: { $0.tabType == .tableHealth }) {
- selectedTabId = existing.id
- return
- }
- var newTab = QueryTab(title: String(localized: "Table Health"), tabType: .tableHealth)
+ var newTab = QueryTab(title: WindowTitleResolver.title(for: tabType), tabType: tabType)
newTab.tableContext.isEditable = false
newTab.hasUserInteraction = true
tabs.append(newTab)
@@ -639,19 +614,6 @@ final class QueryTabManager: ObservableObject {
return String(format: format, objectRef.displayIdentity)
}
- func addUsersRolesTab() {
- if let existing = tabs.first(where: { $0.tabType == .usersRoles }) {
- selectedTabId = existing.id
- return
- }
- let tabTitle = String(localized: "Users & Roles")
- var newTab = QueryTab(title: tabTitle, tabType: .usersRoles)
- newTab.tableContext.isEditable = false
- newTab.hasUserInteraction = true
- tabs.append(newTab)
- selectedTabId = newTab.id
- }
-
// Returns false when no tab is selected; on true the caller runs the query.
@discardableResult
func replaceTabContent(
diff --git a/apple/TablePro/Models/TableHealth/TableHealthModels.swift b/apple/TablePro/Models/TableHealth/TableHealthModels.swift
index fd3f84c583..cbf616086e 100644
--- a/apple/TablePro/Models/TableHealth/TableHealthModels.swift
+++ b/apple/TablePro/Models/TableHealth/TableHealthModels.swift
@@ -1,19 +1,12 @@
-//
-// TableHealthModels.swift
-// TablePro
-//
-
import Foundation
-enum TableHealthCheckKind: String, CaseIterable, Identifiable {
+enum TableHealthCheckKind: String, Sendable {
case bloat
case unusedIndex
case redundantIndex
case missingPrimaryKey
case unindexedForeignKey
- var id: String { rawValue }
-
var title: String {
switch self {
case .bloat: return String(localized: "Bloated Tables")
@@ -35,39 +28,60 @@ enum TableHealthCheckKind: String, CaseIterable, Identifiable {
}
}
-struct TableHealthFinding: Identifiable, Hashable {
+struct TableHealthFinding: Identifiable, Hashable, Sendable {
let kind: TableHealthCheckKind
- let schema: String
+ // Nil where the engine has no schema level below the database.
+ let schema: String?
let table: String
- /// The index or constraint the finding is about, nil when it is about the whole table.
let objectName: String?
let detail: String
- /// Shown as a preview only: nothing in the report runs it.
let fixSQL: String
var id: String {
- [kind.rawValue, schema, table, objectName ?? ""].joined(separator: "\u{1F}")
+ [kind.rawValue, schema ?? "", table, objectName ?? ""].joined(separator: "\u{1F}")
+ }
+
+ var qualifiedTable: String {
+ guard let schema else { return table }
+ return "\(schema).\(table)"
}
}
-struct TableHealthCheckResult: Identifiable {
- static let findingLimit = 500
+struct TableHealthCheckResult: Identifiable, Sendable {
+ enum Outcome: Equatable, Sendable {
+ case completed
+ case failed(String)
+ // The server records nothing the check could read, so it did not run.
+ case skipped(String)
+ }
let kind: TableHealthCheckKind
var findings: [TableHealthFinding]
- var error: String?
+ var outcome: Outcome
- var id: String { kind.id }
+ var id: String { kind.rawValue }
}
-struct TableHealthReport {
+struct TableHealthReport: Sendable {
+ let database: String
var checks: [TableHealthCheckResult]
- /// The database the server answered for, so a finding opens and fixes there even after the sidebar moves on.
- var databaseName: String?
- /// Index usage counts only since the statistics were last reset, so an index can look unused because they were.
var statisticsNote: String?
var findingCount: Int {
checks.reduce(0) { $0 + $1.findings.count }
}
+
+ func finding(id: TableHealthFinding.ID) -> TableHealthFinding? {
+ for check in checks {
+ if let finding = check.findings.first(where: { $0.id == id }) {
+ return finding
+ }
+ }
+ return nil
+ }
+}
+
+struct TableHealthStatistics: Equatable, Sendable {
+ var note: String?
+ var skippedChecks: [TableHealthCheckKind: String] = [:]
}
diff --git a/apple/TablePro/Resources/Localizable.xcstrings b/apple/TablePro/Resources/Localizable.xcstrings
index 63916cf98c..6069c2a0f5 100644
--- a/apple/TablePro/Resources/Localizable.xcstrings
+++ b/apple/TablePro/Resources/Localizable.xcstrings
@@ -10501,9 +10501,6 @@
},
"%lld files" : {
- },
- "%lld findings" : {
-
},
"%lld in, %lld out" : {
"localizations" : {
@@ -12287,6 +12284,9 @@
}
}
}
+ },
+ "^[%lld finding](inflect: true)" : {
+
},
"^[%lld line](inflect: true)" : {
"localizations" : {
@@ -119768,6 +119768,9 @@
}
}
}
+ },
+ "No table in this database has a recorded read or write since the server started, so an unread index cannot be told from an uncounted one." : {
+
},
"No Table Selected" : {
"localizations" : {
@@ -132562,7 +132565,7 @@
}
}
},
- "Performance Schema is off, so the server records no index usage and no index can show as unused." : {
+ "Performance Schema is off, so the server records no index reads." : {
},
"period" : {
@@ -159711,6 +159714,9 @@
},
"Select a column" : {
+ },
+ "Select a database in the sidebar to check its tables." : {
+
},
"Select a definition to edit it" : {
@@ -184831,9 +184837,6 @@
}
}
}
- },
- "The connection switched databases during the refresh. Refresh again." : {
-
},
"The connection this query was recorded against no longer exists." : {
"comment" : "Error message shown when a query cannot be opened because the connection it was recorded against no longer exists.",
@@ -186762,6 +186765,9 @@
},
"The gcloud login does not allow Cloud SQL sign-in. Run gcloud auth application-default login again, without --scopes." : {
+ },
+ "The global_instrumentation consumer is disabled, so the server records no index reads." : {
+
},
"the group coordinator is not available" : {
"extractionState" : "manual",
@@ -193278,6 +193284,9 @@
}
}
}
+ },
+ "The wait/io/table/sql/handler instrument is disabled, so the server records no index reads." : {
+
},
"The workbook has no worksheet to read." : {
"extractionState" : "manual",
@@ -202421,6 +202430,9 @@
}
}
}
+ },
+ "track_counts is off, so the server records no table or index activity." : {
+
},
"Tradeoffs" : {
"localizations" : {
diff --git a/apple/TablePro/ViewModels/TableHealthViewModel.swift b/apple/TablePro/ViewModels/TableHealthViewModel.swift
index 060f23106e..69d81de2ec 100644
--- a/apple/TablePro/ViewModels/TableHealthViewModel.swift
+++ b/apple/TablePro/ViewModels/TableHealthViewModel.swift
@@ -1,22 +1,20 @@
-//
-// TableHealthViewModel.swift
-// TablePro
-//
-
import Combine
import Foundation
import os
+enum TableHealthError: LocalizedError {
+ case noDatabase
+
+ var errorDescription: String? {
+ String(localized: "Select a database in the sidebar to check its tables.")
+ }
+}
+
@MainActor
final class TableHealthViewModel: ObservableObject {
- typealias Execute = @MainActor (String) async throws -> QueryResult
- /// Throws when the connection has no live driver, so the report says so once instead of once per check.
- typealias ExecutorSource = @MainActor () throws -> Execute
-
nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "TableHealth")
- let databaseType: DatabaseType
- let provider: TableHealthProvider?
+ let provider: (any TableHealthProvider)?
@Published private(set) var report: TableHealthReport?
@Published private(set) var isRefreshing = false
@@ -24,49 +22,26 @@ final class TableHealthViewModel: ObservableObject {
@Published private(set) var loadError: String?
@Published var selectedFindingId: TableHealthFinding.ID?
- private let executorSource: ExecutorSource
+ private let connectionId: UUID
+ private let metadata: any ScopedMetadataProviding
var isSupported: Bool { provider != nil }
var selectedFinding: TableHealthFinding? {
- guard let selectedFindingId, let report else { return nil }
- for check in report.checks {
- if let finding = check.findings.first(where: { $0.id == selectedFindingId }) {
- return finding
- }
- }
- return nil
+ selectedFindingId.flatMap { report?.finding(id: $0) }
}
init(
- databaseType: DatabaseType,
- provider: TableHealthProvider?,
- executorSource: @escaping ExecutorSource
+ connectionId: UUID,
+ provider: (any TableHealthProvider)?,
+ metadata: any ScopedMetadataProviding = DatabaseManager.shared
) {
- self.databaseType = databaseType
+ self.connectionId = connectionId
self.provider = provider
- self.executorSource = executorSource
+ self.metadata = metadata
}
- convenience init(connectionId: UUID, databaseType: DatabaseType, services: AppServices = .live) {
- self.init(
- databaseType: databaseType,
- provider: TableHealthProviderFactory.provider(for: databaseType),
- executorSource: {
- guard services.databaseManager.driver(for: connectionId) != nil else {
- throw DatabaseError.connectionFailed(String(localized: "No active connection"))
- }
- return { query in
- guard let driver = services.databaseManager.driver(for: connectionId) else {
- throw DatabaseError.connectionFailed(String(localized: "No active connection"))
- }
- return try await driver.executeRead(query: query)
- }
- }
- )
- }
-
- /// A pane switch re-runs `.task`, so only the first appearance loads.
+ // A pane switch restarts `.task`, so only the first appearance loads.
func loadIfNeeded() async {
guard report == nil, loadError == nil else { return }
await refresh()
@@ -78,8 +53,15 @@ final class TableHealthViewModel: ObservableObject {
defer { isRefreshing = false }
do {
- let execute = try executorSource()
- let fresh = try await provider.fetchReport(execute: execute)
+ guard let scope = metadata.browseScope(for: connectionId) else { throw DatabaseError.notConnected }
+ guard !scope.isServerScoped else { throw TableHealthError.noDatabase }
+ let fresh = try await metadata.withMetadataDriver(scope: scope, workload: .bulk) { driver in
+ let context = TableHealthQueryContext(database: scope.database) { driver.quoteIdentifier($0) }
+ return try await provider.fetchReport(context: context) { query in
+ try await driver.executeRead(query: query)
+ }
+ }
+ try Task.checkCancellation()
report = Self.merging(fresh, over: report)
loadError = nil
lastRefreshDate = Date()
@@ -92,17 +74,15 @@ final class TableHealthViewModel: ObservableObject {
}
}
- /// A check that failed this time keeps the findings it had, shown under its new error.
+ // A check that failed this time keeps the findings it had, shown under its new error.
static func merging(_ fresh: TableHealthReport, over previous: TableHealthReport?) -> TableHealthReport {
- guard let previous, previous.databaseName == fresh.databaseName else { return fresh }
+ guard let previous, previous.database == fresh.database else { return fresh }
var merged = fresh
- for index in merged.checks.indices where merged.checks[index].error != nil {
- if let prior = previous.checks.first(where: { $0.kind == merged.checks[index].kind }) {
- merged.checks[index].findings = prior.findings
- }
- }
- if merged.statisticsNote == nil {
- merged.statisticsNote = previous.statisticsNote
+ for index in merged.checks.indices {
+ guard case .failed = merged.checks[index].outcome,
+ let prior = previous.checks.first(where: { $0.kind == merged.checks[index].kind })
+ else { continue }
+ merged.checks[index].findings = prior.findings
}
return merged
}
diff --git a/apple/TablePro/ViewModels/TableListingViewModel.swift b/apple/TablePro/ViewModels/TableListingViewModel.swift
index 81cb17dc03..e8322970af 100644
--- a/apple/TablePro/ViewModels/TableListingViewModel.swift
+++ b/apple/TablePro/ViewModels/TableListingViewModel.swift
@@ -25,7 +25,7 @@ internal final class TableListingViewModel: ObservableObject {
scope: DatabaseScope,
loader: TableListingLoader,
metadataProvider: any ScopedMetadataProviding = DatabaseManager.shared,
- refreshRequests: AnyPublisher = AppEvents.shared.tableListingRefreshRequested.eraseToAnyPublisher()
+ refreshRequests: AnyPublisher = AppEvents.shared.tabRefreshRequested.eraseToAnyPublisher()
) {
self.scope = scope
self.loader = loader
diff --git a/apple/TablePro/Views/Components/PaneRefreshControls.swift b/apple/TablePro/Views/Components/PaneRefreshControls.swift
new file mode 100644
index 0000000000..0d276eddd9
--- /dev/null
+++ b/apple/TablePro/Views/Components/PaneRefreshControls.swift
@@ -0,0 +1,31 @@
+import SwiftUI
+
+struct PaneRefreshControls: View {
+ let isRefreshing: Bool
+ let lastRefreshDate: Date?
+ let accessibilityIdentifier: String
+ let refresh: () -> Void
+
+ var body: some View {
+ if isRefreshing {
+ ProgressView()
+ .controlSize(.small)
+ .accessibilityLabel(String(localized: "Refreshing"))
+ } else if let lastRefreshDate {
+ Text(lastRefreshDate, style: .time)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .monospacedDigit()
+ .accessibilityLabel(String(localized: "Last updated"))
+ .accessibilityValue(Text(lastRefreshDate, style: .time))
+ }
+
+ Button(action: refresh) {
+ Label(String(localized: "Refresh"), systemImage: "arrow.clockwise")
+ }
+ .labelStyle(.iconOnly)
+ .disabled(isRefreshing)
+ .help(String(localized: "Refresh"))
+ .accessibilityIdentifier(accessibilityIdentifier)
+ }
+}
diff --git a/apple/TablePro/Views/Main/Child/MainEditorContentView.swift b/apple/TablePro/Views/Main/Child/MainEditorContentView.swift
index 7ee73cbb71..6e217ba703 100644
--- a/apple/TablePro/Views/Main/Child/MainEditorContentView.swift
+++ b/apple/TablePro/Views/Main/Child/MainEditorContentView.swift
@@ -281,6 +281,7 @@ struct MainEditorContentView: View {
userTypeContent(tab: tab, objectRef: objectRef)
} else if let objectRef = tab.display.objectRef {
ObjectSourceTabView(
+ tabId: tab.id,
connectionId: connection.id,
databaseType: connection.type,
objectRef: objectRef,
@@ -303,6 +304,11 @@ struct MainEditorContentView: View {
Group {
if let vm = queryInsightsViewModels[tab.id] {
QueryInsightsView(viewModel: vm, coordinator: coordinator)
+ .onReceive(AppEvents.shared.tabRefreshRequested) { requestedTabId in
+ guard requestedTabId == tab.id,
+ LicenseManager.shared.isFeatureAvailable(.queryInsights) else { return }
+ Task { await vm.reload() }
+ }
} else {
ProgressView(String(localized: "Loading insights…"))
.frame(maxWidth: .infinity, maxHeight: .infinity)
@@ -323,7 +329,11 @@ struct MainEditorContentView: View {
private func tableHealthContent(tab: QueryTab) -> some View {
Group {
if let vm = tableHealthViewModels[tab.id] {
- TableHealthView(viewModel: vm, coordinator: coordinator)
+ TableHealthView(viewModel: vm, actions: tableHealthActions)
+ .onReceive(AppEvents.shared.tabRefreshRequested) { requestedTabId in
+ guard requestedTabId == tab.id else { return }
+ Task { await vm.refresh() }
+ }
} else {
ProgressView(String(localized: "Loading table health…"))
.frame(maxWidth: .infinity, maxHeight: .infinity)
@@ -331,7 +341,7 @@ struct MainEditorContentView: View {
guard tableHealthViewModels[tab.id] == nil else { return }
tableHealthViewModels[tab.id] = TableHealthViewModel(
connectionId: connection.id,
- databaseType: connection.type
+ provider: TableHealthProviderFactory.provider(for: connection.type)
)
}
}
@@ -339,6 +349,22 @@ struct MainEditorContentView: View {
.id(tab.id)
}
+ private var tableHealthActions: TableHealthActions {
+ TableHealthActions(
+ openTable: { finding, database in
+ coordinator.openTableTab(finding.table, schema: finding.schema, database: database)
+ },
+ openFix: { finding, database in
+ coordinator.openQuery(
+ finding.fixSQL,
+ on: connection.id,
+ databaseName: database,
+ intent: .loadIntoFrontTab
+ )
+ }
+ )
+ }
+
// MARK: - Users & Roles Tab Content
@ViewBuilder
@@ -369,6 +395,10 @@ struct MainEditorContentView: View {
Group {
if let vm = serverDashboardViewModels[tab.id] {
ServerDashboardView(viewModel: vm)
+ .onReceive(AppEvents.shared.tabRefreshRequested) { requestedTabId in
+ guard requestedTabId == tab.id else { return }
+ Task { await vm.refreshNow() }
+ }
} else {
ProgressView(String(localized: "Loading dashboard…"))
.frame(maxWidth: .infinity, maxHeight: .infinity)
diff --git a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift
index 0ffd5a5bc6..dec5899f1f 100644
--- a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift
+++ b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift
@@ -456,7 +456,7 @@ extension MainContentCoordinator {
)
/// Asking again for a listing already open brings it up to date, the way Refresh would. A tab
/// opened just now loads when its view first appears, and nothing is listening for this yet.
- AppEvents.shared.tableListingRefreshRequested.send(tabId)
+ AppEvents.shared.tabRefreshRequested.send(tabId)
}
/// The schema the sidebar lists, which a cross-database tab can leave the shared driver off.
diff --git a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryInsights.swift b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryInsights.swift
deleted file mode 100644
index a12eaa7a7f..0000000000
--- a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryInsights.swift
+++ /dev/null
@@ -1,34 +0,0 @@
-import AppKit
-import Foundation
-
-extension MainContentCoordinator {
- /// Open (or focus) the Query Insights tab for this connection.
- ///
- /// Singleton per connection, resolved the same way the Server Dashboard is:
- /// 1. If any window for this connection already hosts an insights tab, focus that window.
- /// 2. If this window's tabManager is empty, add the tab locally.
- /// 3. Otherwise open a new native window tab so the current tab's content is preserved.
- func showQueryInsights() {
- if let existing = Self.coordinator(forConnection: connectionId, tabMatching: {
- $0.tabType == .insights
- }), let match = existing.tabManager.tabs.first(where: { $0.tabType == .insights }) {
- /// Selecting it, not just raising its window. An editor tab used to be a window, so
- /// raising the window was the whole of showing the tab; now a window holds every tab
- /// and the command did nothing whenever the tab it names is not the one in front.
- existing.selectTabAndFocusWindow(match.id)
- return
- }
-
- if tabManager.tabs.isEmpty {
- tabManager.addQueryInsightsTab()
- return
- }
-
- let payload = EditorTabPayload(
- connectionId: connection.id,
- tabType: .insights,
- databaseName: browseDatabaseName
- )
- WindowManager.shared.openTab(payload: payload)
- }
-}
diff --git a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+Refresh.swift b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+Refresh.swift
index 3bc6fa3447..e95d3fb3c2 100644
--- a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+Refresh.swift
+++ b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+Refresh.swift
@@ -43,19 +43,20 @@ extension MainContentCoordinator {
onDiscard: @escaping () -> Void
) {
guard let (tab, _) = tabManager.selectedTabAndIndex else { return }
- if tab.tabType == .versionHistory {
- AppEvents.shared.versionHistoryRefreshRequested.send(tab.id)
+ switch tab.tabType {
+ case .versionHistory, .tableListing, .serverDashboard, .insights, .tableHealth, .objectSource:
+ AppEvents.shared.tabRefreshRequested.send(tab.id)
return
- }
- if tab.tabType == .tableListing {
- AppEvents.shared.tableListingRefreshRequested.send(tab.id)
- return
- }
- if tab.tabType == .erDiagram {
+ case .erDiagram:
if let diagram = erDiagramModels[tab.id] {
Task { await diagram.refresh() }
}
return
+ case .usersRoles:
+ usersRolesActions?.refresh()
+ return
+ case .query, .table, .createTable, .routineDebug:
+ break
}
if tab.display.resultsViewMode == .structure {
activeStructureSession?.refresh()
diff --git a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+ServerDashboard.swift b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+ServerDashboard.swift
deleted file mode 100644
index b1e14d4d63..0000000000
--- a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+ServerDashboard.swift
+++ /dev/null
@@ -1,36 +0,0 @@
-import AppKit
-import Foundation
-
-extension MainContentCoordinator {
- /// Open (or focus) the Server Dashboard tab for this connection.
- ///
- /// Singleton per connection. Resolution order:
- /// 1. If any window for this connection already hosts a Server Dashboard
- /// tab, focus that window.
- /// 2. If this window's tabManager is empty, add the dashboard tab locally.
- /// 3. Otherwise open a new native window tab so the current tab's content
- /// is preserved.
- func showServerDashboard() {
- if let existing = Self.coordinator(forConnection: connectionId, tabMatching: {
- $0.tabType == .serverDashboard
- }), let match = existing.tabManager.tabs.first(where: { $0.tabType == .serverDashboard }) {
- /// Selecting it, not just raising its window. An editor tab used to be a window, so
- /// raising the window was the whole of showing the tab; now a window holds every tab
- /// and the command did nothing whenever the tab it names is not the one in front.
- existing.selectTabAndFocusWindow(match.id)
- return
- }
-
- if tabManager.tabs.isEmpty {
- tabManager.addServerDashboardTab()
- return
- }
-
- let payload = EditorTabPayload(
- connectionId: connection.id,
- tabType: .serverDashboard,
- databaseName: browseDatabaseName
- )
- WindowManager.shared.openTab(payload: payload)
- }
-}
diff --git a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+SingletonTabs.swift b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+SingletonTabs.swift
new file mode 100644
index 0000000000..05cbbffa7c
--- /dev/null
+++ b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+SingletonTabs.swift
@@ -0,0 +1,24 @@
+import Foundation
+
+extension MainContentCoordinator {
+ // Focuses the connection's open tab of this type, else adds it here when this window is empty,
+ // else opens a window tab so the current tab stays.
+ func showSingletonTab(_ tabType: TabType) {
+ if let existing = Self.coordinator(forConnection: connectionId, tabMatching: { $0.tabType == tabType }),
+ let match = existing.tabManager.tabs.first(where: { $0.tabType == tabType }) {
+ existing.selectTabAndFocusWindow(match.id)
+ return
+ }
+
+ if tabManager.tabs.isEmpty {
+ tabManager.addSingletonTab(tabType)
+ return
+ }
+
+ WindowManager.shared.openTab(payload: EditorTabPayload(
+ connectionId: connection.id,
+ tabType: tabType,
+ databaseName: browseDatabaseName
+ ))
+ }
+}
diff --git a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+TableHealth.swift b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+TableHealth.swift
deleted file mode 100644
index eb2b308785..0000000000
--- a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+TableHealth.swift
+++ /dev/null
@@ -1,45 +0,0 @@
-import AppKit
-import Foundation
-
-extension MainContentCoordinator {
- /// Open (or focus) the Table Health tab for this connection, one per connection like the Server Dashboard.
- func showTableHealth() {
- if let existing = Self.coordinator(forConnection: connectionId, tabMatching: {
- $0.tabType == .tableHealth
- }), let match = existing.tabManager.tabs.first(where: { $0.tabType == .tableHealth }) {
- existing.selectTabAndFocusWindow(match.id)
- return
- }
-
- if tabManager.tabs.isEmpty {
- tabManager.addTableHealthTab()
- return
- }
-
- let payload = EditorTabPayload(
- connectionId: connection.id,
- tabType: .tableHealth,
- databaseName: browseDatabaseName
- )
- WindowManager.shared.openTab(payload: payload)
- }
-
- /// `reportDatabase` is the database the report was read from, which the sidebar may have left since.
- func openTableHealthFinding(_ finding: TableHealthFinding, reportDatabase: String?, schemaNamesDatabase: Bool) {
- if schemaNamesDatabase {
- openTableTab(finding.table, database: finding.schema)
- } else {
- openTableTab(finding.table, schema: finding.schema, database: reportDatabase)
- }
- }
-
- /// Puts the fix in an editor tab and stops there: the user reads it and runs it themselves.
- func openTableHealthFix(_ finding: TableHealthFinding, reportDatabase: String?, schemaNamesDatabase: Bool) {
- openQuery(
- finding.fixSQL,
- on: connectionId,
- databaseName: schemaNamesDatabase ? finding.schema : reportDatabase ?? browseDatabaseName,
- intent: .loadIntoFrontTab
- )
- }
-}
diff --git a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+UsersRoles.swift b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+UsersRoles.swift
deleted file mode 100644
index 6895cbe145..0000000000
--- a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+UsersRoles.swift
+++ /dev/null
@@ -1,28 +0,0 @@
-import AppKit
-import Foundation
-
-extension MainContentCoordinator {
- func showUsersAndRoles() {
- if let existing = Self.coordinator(forConnection: connectionId, tabMatching: {
- $0.tabType == .usersRoles
- }), let match = existing.tabManager.tabs.first(where: { $0.tabType == .usersRoles }) {
- /// Selecting it, not just raising its window. An editor tab used to be a window, so
- /// raising the window was the whole of showing the tab; now a window holds every tab
- /// and the command did nothing whenever the tab it names is not the one in front.
- existing.selectTabAndFocusWindow(match.id)
- return
- }
-
- if tabManager.tabs.isEmpty {
- tabManager.addUsersRolesTab()
- return
- }
-
- let payload = EditorTabPayload(
- connectionId: connection.id,
- tabType: .usersRoles,
- databaseName: browseDatabaseName
- )
- WindowManager.shared.openTab(payload: payload)
- }
-}
diff --git a/apple/TablePro/Views/Main/MainContentCommandActions+DatabaseTools.swift b/apple/TablePro/Views/Main/MainContentCommandActions+DatabaseTools.swift
index cb4c589d2f..5500dd2acf 100644
--- a/apple/TablePro/Views/Main/MainContentCommandActions+DatabaseTools.swift
+++ b/apple/TablePro/Views/Main/MainContentCommandActions+DatabaseTools.swift
@@ -24,15 +24,15 @@ extension MainContentCommandActions {
}
func showServerDashboard() {
- coordinator?.showServerDashboard()
+ coordinator?.showSingletonTab(.serverDashboard)
}
func showQueryInsights() {
- coordinator?.showQueryInsights()
+ coordinator?.showSingletonTab(.insights)
}
func showTableHealth() {
- coordinator?.showTableHealth()
+ coordinator?.showSingletonTab(.tableHealth)
}
var supportsServerDashboard: Bool {
@@ -40,8 +40,13 @@ extension MainContentCommandActions {
return ServerDashboardQueryProviderFactory.supportsDashboard(for: type)
}
+ var supportsTableHealth: Bool {
+ guard let type = coordinator?.connection.type else { return false }
+ return TableHealthProviderFactory.provider(for: type) != nil
+ }
+
func showUsersAndRoles() {
- coordinator?.showUsersAndRoles()
+ coordinator?.showSingletonTab(.usersRoles)
}
var supportsUserManagement: Bool {
diff --git a/apple/TablePro/Views/ObjectSource/ObjectSourceTabView.swift b/apple/TablePro/Views/ObjectSource/ObjectSourceTabView.swift
index d0ec2ebc80..3fa873cc87 100644
--- a/apple/TablePro/Views/ObjectSource/ObjectSourceTabView.swift
+++ b/apple/TablePro/Views/ObjectSource/ObjectSourceTabView.swift
@@ -119,6 +119,7 @@ final class ObjectSourceLoader: ObservableObject {
}
struct ObjectSourceTabView: View {
+ let tabId: UUID
let connectionId: UUID
let databaseType: DatabaseType
let objectRef: DatabaseObjectRef
@@ -127,11 +128,13 @@ struct ObjectSourceTabView: View {
@StateObject private var loader: ObjectSourceLoader
init(
+ tabId: UUID,
connectionId: UUID,
databaseType: DatabaseType,
objectRef: DatabaseObjectRef,
onOpenInEditor: @escaping (String) -> Void
) {
+ self.tabId = tabId
self.connectionId = connectionId
self.databaseType = databaseType
self.objectRef = objectRef
@@ -146,6 +149,10 @@ struct ObjectSourceTabView: View {
content
}
.task { await loader.loadIfNeeded() }
+ .onReceive(AppEvents.shared.tabRefreshRequested) { requestedTabId in
+ guard requestedTabId == tabId else { return }
+ Task { await loader.load(isRefresh: true) }
+ }
}
private var header: some View {
diff --git a/apple/TablePro/Views/QueryInsights/QueryInsightsToolbar.swift b/apple/TablePro/Views/QueryInsights/QueryInsightsToolbar.swift
index 974145e9a7..d0f70494a3 100644
--- a/apple/TablePro/Views/QueryInsights/QueryInsightsToolbar.swift
+++ b/apple/TablePro/Views/QueryInsights/QueryInsightsToolbar.swift
@@ -12,19 +12,13 @@ struct QueryInsightsToolbar: View {
Spacer(minLength: 8)
- if viewModel.isRefreshing {
- ProgressView()
- .controlSize(.small)
- .accessibilityLabel(String(localized: "Refreshing"))
- } else if let date = viewModel.lastRefreshDate {
- Text(date, style: .time)
- .font(.caption)
- .foregroundStyle(.secondary)
- .monospacedDigit()
- .accessibilityLabel(String(localized: "Last updated"))
+ PaneRefreshControls(
+ isRefreshing: viewModel.isRefreshing,
+ lastRefreshDate: viewModel.lastRefreshDate,
+ accessibilityIdentifier: "query-insights-refresh-button"
+ ) {
+ Task { await viewModel.reload() }
}
-
- refreshButton
}
.padding(.horizontal, 10)
.padding(.vertical, 6)
@@ -91,20 +85,7 @@ struct QueryInsightsToolbar: View {
)
}
- private var refreshButton: some View {
- Button {
- Task { await viewModel.reload() }
- } label: {
- Label(String(localized: "Refresh"), systemImage: "arrow.clockwise")
- }
- .labelStyle(.iconOnly)
- .disabled(viewModel.isRefreshing)
- .help(String(localized: "Refresh"))
- .accessibilityIdentifier("query-insights-refresh-button")
- }
-
- /// A source the user turns off is a source they do not want counted, but an empty set would
- /// filter every panel to nothing, so the last one on stays on.
+ // The last source on stays on: an empty set would filter every panel to nothing.
private func binding(for source: QueryHistorySource) -> Binding {
Binding(
get: { viewModel.sources.contains(source) },
diff --git a/apple/TablePro/Views/TableHealth/TableHealthView.swift b/apple/TablePro/Views/TableHealth/TableHealthView.swift
index 1bfe0007d2..576a42c5f5 100644
--- a/apple/TablePro/Views/TableHealth/TableHealthView.swift
+++ b/apple/TablePro/Views/TableHealth/TableHealthView.swift
@@ -1,14 +1,24 @@
import SwiftUI
+@MainActor
+struct TableHealthActions {
+ let openTable: (_ finding: TableHealthFinding, _ database: String) -> Void
+ let openFix: (_ finding: TableHealthFinding, _ database: String) -> Void
+
+ func copyFix(_ finding: TableHealthFinding) {
+ ClipboardService.shared.writeText(finding.fixSQL)
+ }
+}
+
struct TableHealthView: View {
@ObservedObject var viewModel: TableHealthViewModel
- @ObservedObject var coordinator: MainContentCoordinator
+ let actions: TableHealthActions
- // A VStack, not safeAreaInset: the inset does not reach the List inside VSplitView, whose first rows then sit under the bar.
+ // A VStack, not safeAreaInset: the inset does not reach a List hosted inside the split view.
var body: some View {
VStack(spacing: 0) {
if viewModel.isSupported {
- toolbar
+ header
}
content
}
@@ -24,12 +34,17 @@ struct TableHealthView: View {
systemImage: "stethoscope",
description: Text("Table health is available for PostgreSQL, MySQL and MariaDB.")
)
- } else if let report = viewModel.report {
- VSplitView {
- findingsList(report)
- .frame(minHeight: 160)
- detailPane
- .frame(minHeight: 120, idealHeight: 180)
+ } else if viewModel.report != nil {
+ AutosavingSplitView(
+ autosaveName: "com.TablePro.tableHealth.findingsDetail",
+ isVertical: false,
+ primaryMinimum: 160,
+ secondaryMinimum: 120,
+ primaryThicknessFraction: 0.65
+ ) {
+ TableHealthFindingsList(viewModel: viewModel, actions: actions)
+ } secondary: {
+ TableHealthDetailPane(viewModel: viewModel, actions: actions)
}
} else if let error = viewModel.loadError {
UnavailableStateView {
@@ -47,50 +62,32 @@ struct TableHealthView: View {
}
}
- // MARK: - Toolbar
-
- private var toolbar: some View {
+ private var header: some View {
VStack(spacing: 0) {
HStack(spacing: 8) {
if let report = viewModel.report {
- if let database = report.databaseName {
- Text(verbatim: database)
- .font(.callout.weight(.semibold))
- }
- Text(String(format: String(localized: "%lld findings"), report.findingCount))
+ Text(verbatim: report.database)
+ .font(.callout.weight(.semibold))
+ Text("^[\(report.findingCount) finding](inflect: true)")
.font(.callout)
.monospacedDigit()
- }
- if let error = viewModel.loadError, viewModel.report != nil {
- Image(systemName: "exclamationmark.triangle")
- .foregroundStyle(.secondary)
- .help(error)
- .accessibilityLabel(error)
+ if let error = viewModel.loadError {
+ Image(systemName: "exclamationmark.triangle")
+ .foregroundStyle(.secondary)
+ .help(error)
+ .accessibilityLabel(error)
+ }
}
Spacer(minLength: 8)
- if viewModel.isRefreshing {
- ProgressView()
- .controlSize(.small)
- .accessibilityLabel(String(localized: "Refreshing"))
- } else if let date = viewModel.lastRefreshDate {
- Text(date, style: .time)
- .font(.caption)
- .foregroundStyle(.secondary)
- .monospacedDigit()
- .accessibilityLabel(String(localized: "Last updated"))
- }
-
- Button {
+ PaneRefreshControls(
+ isRefreshing: viewModel.isRefreshing,
+ lastRefreshDate: viewModel.lastRefreshDate,
+ accessibilityIdentifier: "table-health-refresh-button"
+ ) {
Task { await viewModel.refresh() }
- } label: {
- Image(systemName: "arrow.clockwise")
}
- .buttonStyle(.borderless)
- .help(String(localized: "Refresh Now"))
- .accessibilityLabel(String(localized: "Refresh Now"))
- .disabled(viewModel.isRefreshing)
}
.padding(.horizontal, 10)
.padding(.vertical, 6)
@@ -99,89 +96,110 @@ struct TableHealthView: View {
}
.paneBarBackground()
}
+}
- // MARK: - Findings
+private struct TableHealthFindingsList: View {
+ @ObservedObject var viewModel: TableHealthViewModel
+ let actions: TableHealthActions
- private func findingsList(_ report: TableHealthReport) -> some View {
- List(selection: $viewModel.selectedFindingId) {
- if let note = report.statisticsNote {
- Section {
- Label(note, systemImage: "info.circle")
- .font(.callout)
- .foregroundStyle(.secondary)
+ var body: some View {
+ if let report = viewModel.report {
+ List(selection: $viewModel.selectedFindingId) {
+ if let note = report.statisticsNote {
+ Section {
+ Label(note, systemImage: "info.circle")
+ .font(.callout)
+ .foregroundStyle(.secondary)
+ }
}
- }
- ForEach(report.checks) { check in
- Section {
- checkRows(check)
- } header: {
- HStack {
- Label(check.kind.title, systemImage: check.kind.systemImage)
- Spacer()
- if check.error == nil || !check.findings.isEmpty {
- Text(verbatim: "\(check.findings.count)")
- .monospacedDigit()
+ ForEach(report.checks) { check in
+ Section {
+ rows(check)
+ } header: {
+ HStack {
+ Label(check.kind.title, systemImage: check.kind.systemImage)
+ Spacer()
+ if check.outcome == .completed || !check.findings.isEmpty {
+ Text(verbatim: "\(check.findings.count)")
+ .monospacedDigit()
+ }
}
}
}
}
- }
- .contextMenu(forSelectionType: TableHealthFinding.ID.self) { ids in
- if let finding = finding(in: ids) {
- Button(String(localized: "Open Table")) { openTable(finding) }
- Button(String(localized: "Open Fix in Editor")) { openFix(finding) }
- Button(String(localized: "Copy Fix SQL")) { copyFix(finding) }
+ .contextMenu(forSelectionType: TableHealthFinding.ID.self) { ids in
+ if let finding = finding(in: ids, of: report) {
+ Button(String(localized: "Open Table")) { actions.openTable(finding, report.database) }
+ Button(String(localized: "Open Fix in Editor")) { actions.openFix(finding, report.database) }
+ Button(String(localized: "Copy Fix SQL")) { actions.copyFix(finding) }
+ }
+ } primaryAction: { ids in
+ if let finding = finding(in: ids, of: report) {
+ actions.openTable(finding, report.database)
+ }
}
- } primaryAction: { ids in
- if let finding = finding(in: ids) { openTable(finding) }
}
}
@ViewBuilder
- private func checkRows(_ check: TableHealthCheckResult) -> some View {
- if let error = check.error {
- Label {
- Text(verbatim: error)
- } icon: {
- Image(systemName: "exclamationmark.triangle")
+ private func rows(_ check: TableHealthCheckResult) -> some View {
+ switch check.outcome {
+ case .failed(let message):
+ status(message, systemImage: "exclamationmark.triangle")
+ case .skipped(let reason):
+ status(reason, systemImage: "info.circle")
+ case .completed:
+ if check.findings.isEmpty {
+ Text("No findings.")
+ .font(.callout)
+ .foregroundStyle(.secondary)
}
- .font(.callout)
- .foregroundStyle(.secondary)
- } else if check.findings.isEmpty {
- Text("No findings.")
- .font(.callout)
- .foregroundStyle(.secondary)
}
- if !check.findings.isEmpty {
- ForEach(check.findings) { finding in
- VStack(alignment: .leading, spacing: 2) {
- Text(verbatim: Self.qualifiedName(finding))
- Text(verbatim: finding.detail)
- .font(.callout)
- .foregroundStyle(.secondary)
- .lineLimit(2)
- }
- .padding(.vertical, 2)
- .tag(finding.id)
+ ForEach(check.findings) { finding in
+ VStack(alignment: .leading, spacing: 2) {
+ Text(verbatim: finding.qualifiedTable)
+ Text(verbatim: finding.detail)
+ .font(.callout)
+ .foregroundStyle(.secondary)
+ .lineLimit(2)
}
+ .padding(.vertical, 2)
+ .tag(finding.id)
}
}
- // MARK: - Detail
+ private func status(_ message: String, systemImage: String) -> some View {
+ Label {
+ Text(verbatim: message)
+ } icon: {
+ Image(systemName: systemImage)
+ }
+ .font(.callout)
+ .foregroundStyle(.secondary)
+ }
- @ViewBuilder
- private var detailPane: some View {
- if let finding = viewModel.selectedFinding {
+ private func finding(in ids: Set, of report: TableHealthReport) -> TableHealthFinding? {
+ guard ids.count == 1, let id = ids.first else { return nil }
+ return report.finding(id: id)
+ }
+}
+
+private struct TableHealthDetailPane: View {
+ @ObservedObject var viewModel: TableHealthViewModel
+ let actions: TableHealthActions
+
+ var body: some View {
+ if let finding = viewModel.selectedFinding, let database = viewModel.report?.database {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
- Text(verbatim: Self.qualifiedName(finding))
+ Text(verbatim: finding.qualifiedTable)
.font(.headline)
.lineLimit(1)
.truncationMode(.middle)
Spacer(minLength: 8)
- Button(String(localized: "Open Table")) { openTable(finding) }
- Button(String(localized: "Copy")) { copyFix(finding) }
- Button(String(localized: "Open in Editor")) { openFix(finding) }
+ Button(String(localized: "Open Table")) { actions.openTable(finding, database) }
+ Button(String(localized: "Copy")) { actions.copyFix(finding) }
+ Button(String(localized: "Open in Editor")) { actions.openFix(finding, database) }
}
ScrollView {
Text(verbatim: finding.fixSQL)
@@ -201,39 +219,4 @@ struct TableHealthView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
-
- // MARK: - Actions
-
- private var schemaNamesDatabase: Bool {
- viewModel.provider?.schemaNamesDatabase ?? false
- }
-
- private func finding(in ids: Set) -> TableHealthFinding? {
- guard ids.count == 1, let id = ids.first, let report = viewModel.report else { return nil }
- return report.checks.lazy.flatMap(\.findings).first { $0.id == id }
- }
-
- private func openTable(_ finding: TableHealthFinding) {
- coordinator.openTableHealthFinding(
- finding,
- reportDatabase: viewModel.report?.databaseName,
- schemaNamesDatabase: schemaNamesDatabase
- )
- }
-
- private func openFix(_ finding: TableHealthFinding) {
- coordinator.openTableHealthFix(
- finding,
- reportDatabase: viewModel.report?.databaseName,
- schemaNamesDatabase: schemaNamesDatabase
- )
- }
-
- private func copyFix(_ finding: TableHealthFinding) {
- ClipboardService.shared.writeText(finding.fixSQL)
- }
-
- static func qualifiedName(_ finding: TableHealthFinding) -> String {
- finding.schema.isEmpty ? finding.table : "\(finding.schema).\(finding.table)"
- }
}
diff --git a/apple/TablePro/Views/VersionHistory/VersionHistoryTabView.swift b/apple/TablePro/Views/VersionHistory/VersionHistoryTabView.swift
index b89ebc9414..4ba7e76993 100644
--- a/apple/TablePro/Views/VersionHistory/VersionHistoryTabView.swift
+++ b/apple/TablePro/Views/VersionHistory/VersionHistoryTabView.swift
@@ -34,7 +34,7 @@ internal struct VersionHistoryTabView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity)
.themeBackground(.windowBackdrop, native: Color(nsColor: .textBackgroundColor))
.task { await viewModel.loadList() }
- .onReceive(AppEvents.shared.versionHistoryRefreshRequested) { requestedTabId in
+ .onReceive(AppEvents.shared.tabRefreshRequested) { requestedTabId in
guard requestedTabId == tabId else { return }
viewModel.reload()
}
diff --git a/apple/TableProTests/Core/Menu/MainMenuBuilderTests.swift b/apple/TableProTests/Core/Menu/MainMenuBuilderTests.swift
index 2a01ca20ff..3b9cae5f9d 100644
--- a/apple/TableProTests/Core/Menu/MainMenuBuilderTests.swift
+++ b/apple/TableProTests/Core/Menu/MainMenuBuilderTests.swift
@@ -727,12 +727,35 @@ struct MainMenuValidationTests {
#expect(!enabled(#selector(MainSplitViewController.showServerDashboard(_:)), context))
#expect(!enabled(#selector(MainSplitViewController.showUsersAndRoles(_:)), context))
#expect(!enabled(#selector(MainSplitViewController.openContainerSwitcher(_:)), context))
+ #expect(!enabled(#selector(MainSplitViewController.showTableHealth(_:)), context))
context.supportsServerDashboard = true
context.supportsUserManagement = true
context.supportsContainerSwitching = true
+ context.supportsTableHealth = true
#expect(enabled(#selector(MainSplitViewController.showServerDashboard(_:)), context))
#expect(enabled(#selector(MainSplitViewController.showUsersAndRoles(_:)), context))
#expect(enabled(#selector(MainSplitViewController.openContainerSwitcher(_:)), context))
+ #expect(enabled(#selector(MainSplitViewController.showTableHealth(_:)), context))
+ }
+
+ // Each opens a tab in browse content, which Agent mode does not mount.
+ @Test("Agent mode dims the commands that open a connection's tool tabs")
+ func toolTabsDimInAgentMode() {
+ var context = capableContext()
+ context.isConnected = true
+ let toolTabs = [
+ #selector(MainSplitViewController.showServerDashboard(_:)),
+ #selector(MainSplitViewController.showUsersAndRoles(_:)),
+ #selector(MainSplitViewController.showQueryInsights(_:)),
+ #selector(MainSplitViewController.showTableHealth(_:))
+ ]
+ for selector in toolTabs {
+ #expect(enabled(selector, context), "\(selector) is dim while browsing")
+ }
+ context.isAgentMode = true
+ for selector in toolTabs {
+ #expect(!enabled(selector, context), "\(selector) stayed lit in Agent mode")
+ }
}
// Everything else is switched on, so isConnected is the only thing left that can disable them.
@@ -759,6 +782,7 @@ struct MainMenuValidationTests {
context.supportsContainerSwitching = true
context.supportsServerDashboard = true
context.supportsUserManagement = true
+ context.supportsTableHealth = true
context.isCurrentTabEditable = true
context.isCurrentTabSchemaResolved = true
context.hasTableSelection = true
@@ -803,6 +827,7 @@ struct MainMenuValidationTests {
#selector(MainSplitViewController.openContainerSwitcher(_:)),
#selector(MainSplitViewController.showServerDashboard(_:)),
#selector(MainSplitViewController.showUsersAndRoles(_:)),
+ #selector(MainSplitViewController.showTableHealth(_:)),
#selector(MainSplitViewController.toggleFilterBar(_:)),
#selector(MainSplitViewController.showHighlightRules(_:))
]
diff --git a/apple/TableProTests/Core/Storage/QueryHistoryInsightsTests.swift b/apple/TableProTests/Core/Storage/QueryHistoryInsightsTests.swift
index d62bbe8c62..985762e934 100644
--- a/apple/TableProTests/Core/Storage/QueryHistoryInsightsTests.swift
+++ b/apple/TableProTests/Core/Storage/QueryHistoryInsightsTests.swift
@@ -581,7 +581,7 @@ struct QueryHistoryInsightsTests {
@MainActor
func insightsTabIsOnePerConnection() throws {
let tabManager = QueryTabManager()
- tabManager.addQueryInsightsTab()
+ tabManager.addSingletonTab(.insights)
let insights = try #require(tabManager.tabs.first { $0.tabType == .insights })
#expect(tabManager.selectedTabId == insights.id)
@@ -589,7 +589,7 @@ struct QueryHistoryInsightsTests {
tabManager.selectedTabId = tabManager.tabs.last?.id
#expect(tabManager.selectedTabId != insights.id)
- tabManager.addQueryInsightsTab()
+ tabManager.addSingletonTab(.insights)
#expect(tabManager.tabs.filter { $0.tabType == .insights }.map(\.id) == [insights.id])
#expect(tabManager.selectedTabId == insights.id)
diff --git a/apple/TableProTests/Core/TableHealth/MySQLTableHealthProviderTests.swift b/apple/TableProTests/Core/TableHealth/MySQLTableHealthProviderTests.swift
index 7466ac9d60..643db75341 100644
--- a/apple/TableProTests/Core/TableHealth/MySQLTableHealthProviderTests.swift
+++ b/apple/TableProTests/Core/TableHealth/MySQLTableHealthProviderTests.swift
@@ -1,8 +1,3 @@
-//
-// MySQLTableHealthProviderTests.swift
-// TableProTests
-//
-
import Foundation
@testable import TablePro
import TableProPluginKit
@@ -10,36 +5,87 @@ import Testing
struct MySQLTableHealthProviderTests {
private let provider = MySQLTableHealthProvider()
+ private let context = TableHealthFixture.mysqlContext()
- private typealias IndexRow = (index: String, nonUnique: String, type: String, column: String?, subPart: String?)
+ private struct IndexRow {
+ var table = "orders"
+ let index: String
+ let nonUnique: String
+ var type = "BTREE"
+ let column: String?
+ var subPart: String?
+ var collation: String? = "A"
+ var visible: String?
+ }
+ // The columns a SELECT * on STATISTICS returns, as MySQL 8 names them.
private static let indexColumns = [
- "schema_name", "table_name", "index_name", "non_unique", "index_type", "column_name", "sub_part", "sort_order"
+ "TABLE_NAME", "INDEX_NAME", "NON_UNIQUE", "INDEX_TYPE", "COLUMN_NAME", "SUB_PART", "COLLATION", "IS_VISIBLE"
]
- private func redundant(in rows: [IndexRow], table: String = "orders", sortOrders: [String?]? = nil) -> [String: String] {
- let result = TableHealthFixture.result(
- columns: Self.indexColumns,
- rows: rows.enumerated().map { position, row -> [String?] in
- let sortOrder = sortOrders?[position] ?? (row.type == "BTREE" ? "A" : nil)
- return ["shop", table, row.index, row.nonUnique, row.type, row.column, row.subPart, sortOrder]
+ private static func indexResult(_ rows: [IndexRow]) -> QueryResult {
+ TableHealthFixture.result(
+ columns: indexColumns,
+ rows: rows.map { row -> [String?] in
+ [row.table, row.index, row.nonUnique, row.type, row.column, row.subPart, row.collation, row.visible]
}
)
+ }
+
+ private func redundant(_ rows: [IndexRow]) -> [String: String] {
var map: [String: String] = [:]
- for finding in provider.findings(for: .redundantIndex, from: result) {
+ for finding in provider.findings(for: .redundantIndex, from: [Self.indexResult(rows)], context: context) {
map[finding.objectName ?? ""] = finding.detail
}
return map
}
+ private func unused(
+ candidates: [(table: String, index: String)],
+ indexes: [IndexRow],
+ foreignKeys: [(table: String, constraint: String, column: String)] = []
+ ) -> [String] {
+ let results = [
+ TableHealthFixture.result(
+ columns: ["table_name", "index_name"],
+ rows: candidates.map { candidate -> [String?] in [candidate.table, candidate.index] }
+ ),
+ Self.indexResult(indexes),
+ TableHealthFixture.result(
+ columns: ["table_name", "constraint_name", "column_name"],
+ rows: foreignKeys.map { key -> [String?] in [key.table, key.constraint, key.column] }
+ )
+ ]
+ return provider.findings(for: .unusedIndex, from: results, context: context).compactMap(\.objectName)
+ }
+
+ private func statistics(
+ performanceSchema: String = "1",
+ instrument: String = "YES",
+ consumer: String = "YES",
+ countedTables: String = "3"
+ ) -> TableHealthStatistics {
+ provider.statistics(
+ from: [
+ TableHealthFixture.result(columns: ["performance_schema"], rows: [[performanceSchema]]),
+ TableHealthFixture.result(columns: ["Variable_name", "Value"], rows: [["Uptime", "3600"]]),
+ TableHealthFixture.result(
+ columns: ["table_io_instrument", "global_instrumentation", "counted_tables"],
+ rows: [[instrument, consumer, countedTables]]
+ )
+ ],
+ now: Date(timeIntervalSince1970: 1_800_000_000)
+ )
+ }
+
@Test("An index whose columns lead another index is redundant to it")
func prefixIsRedundant() {
- let found = redundant(in: [
- ("PRIMARY", "0", "BTREE", "id", nil),
- ("idx_a", "1", "BTREE", "a", nil),
- ("idx_ab", "1", "BTREE", "a", nil),
- ("idx_ab", "1", "BTREE", "b", nil),
- ("uniq_b", "0", "BTREE", "b", nil)
+ let found = redundant([
+ IndexRow(index: "PRIMARY", nonUnique: "0", column: "id"),
+ IndexRow(index: "idx_a", nonUnique: "1", column: "a"),
+ IndexRow(index: "idx_ab", nonUnique: "1", column: "a"),
+ IndexRow(index: "idx_ab", nonUnique: "1", column: "b"),
+ IndexRow(index: "uniq_b", nonUnique: "0", column: "b")
])
#expect(Array(found.keys) == ["idx_a"])
#expect(found["idx_a"]?.contains("idx_ab") == true)
@@ -47,64 +93,64 @@ struct MySQLTableHealthProviderTests {
@Test("A shorter unique index enforces more than the longer one, so it stays")
func shorterUniqueStays() {
- let found = redundant(in: [
- ("uniq_a", "0", "BTREE", "a", nil),
- ("idx_ab", "1", "BTREE", "a", nil),
- ("idx_ab", "1", "BTREE", "b", nil)
+ let found = redundant([
+ IndexRow(index: "uniq_a", nonUnique: "0", column: "a"),
+ IndexRow(index: "idx_ab", nonUnique: "1", column: "a"),
+ IndexRow(index: "idx_ab", nonUnique: "1", column: "b")
])
#expect(found.isEmpty)
}
@Test("Of two equal plain indexes, the one later by name is redundant")
func equalPlainIndexes() {
- let found = redundant(in: [
- ("idx_x", "1", "BTREE", "a", nil),
- ("idx_y", "1", "BTREE", "a", nil)
+ let found = redundant([
+ IndexRow(index: "idx_x", nonUnique: "1", column: "a"),
+ IndexRow(index: "idx_y", nonUnique: "1", column: "a")
])
#expect(Array(found.keys) == ["idx_y"])
}
@Test("A plain index equal to a unique one is redundant to it")
func plainEqualToUnique() {
- let found = redundant(in: [
- ("a_plain", "1", "BTREE", "a", nil),
- ("z_unique", "0", "BTREE", "a", nil)
+ let found = redundant([
+ IndexRow(index: "a_plain", nonUnique: "1", column: "a"),
+ IndexRow(index: "z_unique", nonUnique: "0", column: "a")
])
#expect(Array(found.keys) == ["a_plain"])
}
@Test("A unique index equal to the primary key is redundant, and the primary key never is")
func uniqueEqualToPrimary() {
- let found = redundant(in: [
- ("PRIMARY", "0", "BTREE", "id", nil),
- ("id_unique", "0", "BTREE", "id", nil)
+ let found = redundant([
+ IndexRow(index: "PRIMARY", nonUnique: "0", column: "id"),
+ IndexRow(index: "id_unique", nonUnique: "0", column: "id")
])
#expect(Array(found.keys) == ["id_unique"])
}
@Test("A column prefix length, an expression or another index type keeps an index apart")
func shapesThatDoNotCompare() {
- let found = redundant(in: [
- ("idx_a10", "1", "BTREE", "a", "10"),
- ("idx_ab", "1", "BTREE", "a", nil),
- ("idx_ab", "1", "BTREE", "b", nil),
- ("idx_expr", "1", "BTREE", nil, nil),
- ("idx_expr", "1", "BTREE", "b", nil),
- ("ft_a", "1", "FULLTEXT", "a", nil)
+ let found = redundant([
+ IndexRow(index: "idx_a10", nonUnique: "1", column: "a", subPart: "10"),
+ IndexRow(index: "idx_ab", nonUnique: "1", column: "a"),
+ IndexRow(index: "idx_ab", nonUnique: "1", column: "b"),
+ IndexRow(index: "idx_expr", nonUnique: "1", column: nil),
+ IndexRow(index: "idx_expr", nonUnique: "1", column: "b"),
+ IndexRow(index: "ft_a", nonUnique: "1", type: "FULLTEXT", column: "a", collation: nil)
])
#expect(found.isEmpty)
}
@Test("A full-text index is redundant only to an identical one, never to a longer one")
func fullTextNeedsExactColumns() {
- let prefix = redundant(in: [
- ("ft_a", "1", "FULLTEXT", "a", nil),
- ("ft_ab", "1", "FULLTEXT", "a", nil),
- ("ft_ab", "1", "FULLTEXT", "b", nil)
+ let prefix = redundant([
+ IndexRow(index: "ft_a", nonUnique: "1", type: "FULLTEXT", column: "a", collation: nil),
+ IndexRow(index: "ft_ab", nonUnique: "1", type: "FULLTEXT", column: "a", collation: nil),
+ IndexRow(index: "ft_ab", nonUnique: "1", type: "FULLTEXT", column: "b", collation: nil)
])
- let identical = redundant(in: [
- ("ft_a", "1", "FULLTEXT", "a", nil),
- ("ft_a2", "1", "FULLTEXT", "a", nil)
+ let identical = redundant([
+ IndexRow(index: "ft_a", nonUnique: "1", type: "FULLTEXT", column: "a", collation: nil),
+ IndexRow(index: "ft_a2", nonUnique: "1", type: "FULLTEXT", column: "a", collation: nil)
])
#expect(prefix.isEmpty)
#expect(Array(identical.keys) == ["ft_a2"])
@@ -112,83 +158,164 @@ struct MySQLTableHealthProviderTests {
@Test("A key part sorted the other way keeps an index apart")
func sortDirectionMatters() {
- let found = redundant(
- in: [
- ("idx_ab_desc", "1", "BTREE", "a", nil),
- ("idx_ab_desc", "1", "BTREE", "b", nil),
- ("idx_ab", "1", "BTREE", "a", nil),
- ("idx_ab", "1", "BTREE", "b", nil)
- ],
- sortOrders: ["A", "D", "A", "A"]
- )
+ let found = redundant([
+ IndexRow(index: "idx_ab_desc", nonUnique: "1", column: "a"),
+ IndexRow(index: "idx_ab_desc", nonUnique: "1", column: "b", collation: "D"),
+ IndexRow(index: "idx_ab", nonUnique: "1", column: "a"),
+ IndexRow(index: "idx_ab", nonUnique: "1", column: "b")
+ ])
#expect(found.isEmpty)
}
+ @Test("An index is never redundant to an invisible or ignored one, which serves no query")
+ func invisibleIndexCoversNothing() {
+ let invisible = redundant([
+ IndexRow(index: "idx_a", nonUnique: "1", column: "a"),
+ IndexRow(index: "idx_ab", nonUnique: "1", column: "a", visible: "NO"),
+ IndexRow(index: "idx_ab", nonUnique: "1", column: "b", visible: "NO")
+ ])
+ let ignored = provider.findings(
+ for: .redundantIndex,
+ from: [TableHealthFixture.result(
+ columns: ["TABLE_NAME", "INDEX_NAME", "NON_UNIQUE", "INDEX_TYPE", "COLUMN_NAME", "SUB_PART", "COLLATION", "IGNORED"],
+ rows: [
+ ["orders", "idx_a", "1", "BTREE", "a", nil, "A", "NO"],
+ ["orders", "idx_ab", "1", "BTREE", "a", nil, "A", "YES"],
+ ["orders", "idx_ab", "1", "BTREE", "b", nil, "A", "YES"]
+ ]
+ )],
+ context: context
+ )
+ #expect(invisible.isEmpty)
+ #expect(ignored.isEmpty)
+ }
+
@Test("Indexes compare only within their own table")
func tablesStayApart() {
- let result = TableHealthFixture.result(
- columns: Self.indexColumns,
- rows: [
- ["shop", "a", "idx_x", "1", "BTREE", "c", nil, "A"],
- ["shop", "b", "idx_xy", "1", "BTREE", "c", nil, "A"],
- ["shop", "b", "idx_xy", "1", "BTREE", "d", nil, "A"]
+ let found = redundant([
+ IndexRow(table: "a", index: "idx_x", nonUnique: "1", column: "c"),
+ IndexRow(table: "b", index: "idx_xy", nonUnique: "1", column: "c"),
+ IndexRow(table: "b", index: "idx_xy", nonUnique: "1", column: "d")
+ ])
+ #expect(found.isEmpty)
+ }
+
+ @Test("A redundant index previews a drop qualified by the report's database")
+ func redundantFixQuotes() throws {
+ let finding = try #require(provider.findings(
+ for: .redundantIndex,
+ from: [Self.indexResult([
+ IndexRow(table: "odd`name", index: "idx_a", nonUnique: "1", column: "a"),
+ IndexRow(table: "odd`name", index: "idx_ab", nonUnique: "1", column: "a"),
+ IndexRow(table: "odd`name", index: "idx_ab", nonUnique: "1", column: "b")
+ ])],
+ context: context
+ ).first)
+ #expect(finding.schema == nil)
+ #expect(finding.fixSQL == "ALTER TABLE `shop`.`odd``name` DROP INDEX `idx_a`;")
+ }
+
+ @Test("An unread plain index is unused; a unique index and one gone from the catalog are not")
+ func unusedSkipsUniqueAndDropped() {
+ let found = unused(
+ candidates: [("orders", "orders_note_idx"), ("orders", "orders_code_uniq"), ("orders", "dropped_idx")],
+ indexes: [
+ IndexRow(index: "orders_code_uniq", nonUnique: "0", column: "code"),
+ IndexRow(index: "orders_note_idx", nonUnique: "1", column: "note")
]
)
- #expect(provider.findings(for: .redundantIndex, from: result).isEmpty)
+ #expect(found == ["orders_note_idx"])
}
- @Test("A redundant index previews a drop with backtick quoting")
- func redundantFixQuotes() throws {
- let result = TableHealthFixture.result(
- columns: Self.indexColumns,
- rows: [
- ["shop", "odd`name", "idx_a", "1", "BTREE", "a", nil, "A"],
- ["shop", "odd`name", "idx_ab", "1", "BTREE", "a", nil, "A"],
- ["shop", "odd`name", "idx_ab", "1", "BTREE", "b", nil, "A"]
+ @Test("An index that leads with a foreign key's columns is not unused: InnoDB checks the key through it uncounted")
+ func unusedSkipsForeignKeyIndex() {
+ let found = unused(
+ candidates: [("line", "line_order_idx"), ("line", "line_pair_idx"), ("line", "line_late_idx"), ("line", "line_short_idx")],
+ indexes: [
+ IndexRow(table: "line", index: "line_late_idx", nonUnique: "1", column: "note"),
+ IndexRow(table: "line", index: "line_late_idx", nonUnique: "1", column: "order_id"),
+ IndexRow(table: "line", index: "line_order_idx", nonUnique: "1", column: "ORDER_ID"),
+ IndexRow(table: "line", index: "line_order_idx", nonUnique: "1", column: "note"),
+ IndexRow(table: "line", index: "line_pair_idx", nonUnique: "1", column: "x"),
+ IndexRow(table: "line", index: "line_pair_idx", nonUnique: "1", column: "y"),
+ IndexRow(table: "line", index: "line_short_idx", nonUnique: "1", column: "x", subPart: "4"),
+ IndexRow(table: "line", index: "line_short_idx", nonUnique: "1", column: "y")
+ ],
+ foreignKeys: [
+ ("line", "line_order_fk", "order_id"),
+ ("line", "line_pair_fk", "x"),
+ ("line", "line_pair_fk", "y")
]
)
- let finding = try #require(provider.findings(for: .redundantIndex, from: result).first)
- #expect(finding.fixSQL == "ALTER TABLE `shop`.`odd``name` DROP INDEX `idx_a`;")
+ #expect(found == ["line_late_idx", "line_short_idx"])
}
@Test("An unused index and a table without a primary key preview their fixes")
func unusedAndMissingKeyFixes() throws {
- let unused = TableHealthFixture.result(
- columns: ["schema_name", "table_name", "index_name"],
- rows: [["shop", "orders", "orders_note_idx"]]
- )
- let noKey = TableHealthFixture.result(columns: ["schema_name", "table_name"], rows: [["shop", "logs"]])
-
- let unusedFinding = try #require(provider.findings(for: .unusedIndex, from: unused).first)
- let keyFinding = try #require(provider.findings(for: .missingPrimaryKey, from: noKey).first)
+ let unusedFinding = try #require(provider.findings(
+ for: .unusedIndex,
+ from: [
+ TableHealthFixture.result(columns: ["table_name", "index_name"], rows: [["orders", "orders_note_idx"]]),
+ Self.indexResult([IndexRow(index: "orders_note_idx", nonUnique: "1", column: "note")]),
+ TableHealthFixture.result(columns: ["table_name", "constraint_name", "column_name"], rows: [])
+ ],
+ context: context
+ ).first)
+ let keyFinding = try #require(provider.findings(
+ for: .missingPrimaryKey,
+ from: [TableHealthFixture.result(columns: ["table_name"], rows: [["logs"]])],
+ context: context
+ ).first)
#expect(unusedFinding.fixSQL == "ALTER TABLE `shop`.`orders` DROP INDEX `orders_note_idx`;")
#expect(keyFinding.fixSQL == "ALTER TABLE `shop`.`logs` ADD PRIMARY KEY (column_name);")
- #expect(provider.schemaNamesDatabase)
}
- @Test("With Performance Schema off, the note says no index usage is recorded")
- func performanceSchemaOff() {
- let off = TableHealthFixture.result(columns: ["performance_schema"], rows: [["0"]])
- let uptime = TableHealthFixture.result(columns: ["Variable_name", "Value"], rows: [["Uptime", "3600"]])
- #expect(provider.statisticsNote(from: [off, uptime], now: Date())?.contains("Performance Schema is off") == true)
+ @Test("Each setting that stops index reads being counted skips the unused index check")
+ func uncountedReadsSkipUnused() {
+ #expect(statistics(performanceSchema: "0").skippedChecks[.unusedIndex]?.contains("Performance Schema is off") == true)
+ #expect(statistics(instrument: "NO").skippedChecks[.unusedIndex]?.contains("wait/io/table/sql/handler") == true)
+ #expect(statistics(consumer: "NO").skippedChecks[.unusedIndex]?.contains("global_instrumentation") == true)
+ #expect(statistics(countedTables: "0").skippedChecks[.unusedIndex] != nil)
+ #expect(statistics().skippedChecks.isEmpty)
}
- @Test("With Performance Schema on, the note names the server start")
+ @Test("While reads are counted, the note names the server start")
func serverStartNote() {
- let on = TableHealthFixture.result(columns: ["performance_schema"], rows: [["1"]])
- let uptime = TableHealthFixture.result(columns: ["Variable_name", "Value"], rows: [["Uptime", "3600"]])
- let now = Date(timeIntervalSince1970: 1_800_000_000)
let start = Date(timeIntervalSince1970: 1_800_000_000 - 3600).formatted(date: .abbreviated, time: .shortened)
+ #expect(statistics().note?.contains(start) == true)
+ }
- #expect(provider.statisticsNote(from: [on, uptime], now: now)?.contains(start) == true)
+ @Test("Without access to the counting settings the unused check still runs and reports for itself")
+ func unreadableCountingRunsCheck() {
+ let stats = provider.statistics(
+ from: [
+ TableHealthFixture.result(columns: ["performance_schema"], rows: [["1"]]),
+ nil,
+ nil
+ ],
+ now: Date()
+ )
+ #expect(stats.skippedChecks.isEmpty)
+ #expect(stats.note == nil)
+ }
+
+ @Test("The index catalog is read once for the two checks that use it")
+ func indexCatalogReadOnce() async throws {
+ var asked: [String] = []
+ _ = try await provider.fetchReport(context: context) { query in
+ asked.append(query)
+ return TableHealthFixture.empty()
+ }
+ #expect(asked.filter { $0 == MySQLTableHealthProvider.indexQuery }.count == 1)
}
@Test("Every query the report runs only reads")
func queriesOnlyRead() {
- for check in provider.checks {
- let query = provider.query(for: check).trimmingCharacters(in: .whitespacesAndNewlines)
- #expect(query.uppercased().hasPrefix("SELECT"), "\(check) does not start with SELECT")
+ let queries = provider.statisticsQueries + provider.checks.flatMap { provider.queries(for: $0) }
+ for query in queries where query != MySQLTableHealthProvider.uptimeQuery {
+ let text = query.trimmingCharacters(in: .whitespacesAndNewlines)
+ #expect(text.uppercased().hasPrefix("SELECT"), "\(text.prefix(40)) does not start with SELECT")
}
#expect(MySQLTableHealthProvider.uptimeQuery.hasPrefix("SHOW "))
}
diff --git a/apple/TableProTests/Core/TableHealth/PostgreSQLTableHealthProviderTests.swift b/apple/TableProTests/Core/TableHealth/PostgreSQLTableHealthProviderTests.swift
index 28f653f010..63ae179304 100644
--- a/apple/TableProTests/Core/TableHealth/PostgreSQLTableHealthProviderTests.swift
+++ b/apple/TableProTests/Core/TableHealth/PostgreSQLTableHealthProviderTests.swift
@@ -1,8 +1,3 @@
-//
-// PostgreSQLTableHealthProviderTests.swift
-// TableProTests
-//
-
import Foundation
@testable import TablePro
import TableProPluginKit
@@ -14,6 +9,22 @@ struct PostgreSQLTableHealthProviderTests {
}
private let provider = PostgreSQLTableHealthProvider()
+ private let context = TableHealthFixture.postgresContext()
+
+ private func findings(_ check: TableHealthCheckKind, columns: [String], rows: [[String?]]) -> [TableHealthFinding] {
+ provider.findings(
+ for: check,
+ from: [TableHealthFixture.result(columns: columns, rows: rows)],
+ context: context
+ )
+ }
+
+ private func statistics(trackCounts: String, statsReset: String?) -> TableHealthStatistics {
+ provider.statistics(
+ from: [TableHealthFixture.result(columns: ["track_counts", "stats_reset"], rows: [[trackCounts, statsReset]])],
+ now: Date()
+ )
+ }
@Test("The factory gives PostgreSQL, MySQL and MariaDB a provider and no other engine")
func factoryCoversThreeEngines() {
@@ -26,13 +37,12 @@ struct PostgreSQLTableHealthProviderTests {
@Test("A bloated table names its dead and live rows and previews a vacuum")
func bloatFinding() throws {
- let result = TableHealthFixture.result(
+ let finding = try #require(findings(
+ .bloat,
columns: ["schema_name", "table_name", "live_rows", "dead_rows", "last_vacuum"],
rows: [["public", "orders", "2000", "3000", "2026-10-31 19:48:20+00"]]
- )
- let finding = try #require(provider.findings(for: .bloat, from: result).first)
+ ).first)
- #expect(finding.kind == .bloat)
#expect(finding.schema == "public")
#expect(finding.table == "orders")
#expect(finding.objectName == nil)
@@ -42,21 +52,21 @@ struct PostgreSQLTableHealthProviderTests {
@Test("A table never vacuumed says so")
func neverVacuumed() throws {
- let result = TableHealthFixture.result(
+ let finding = try #require(findings(
+ .bloat,
columns: ["schema_name", "table_name", "live_rows", "dead_rows", "last_vacuum"],
rows: [["public", "orders", "2000", "3000", nil]]
- )
- let finding = try #require(provider.findings(for: .bloat, from: result).first)
+ ).first)
#expect(finding.detail.contains("Never vacuumed"))
}
@Test("An unused index previews a concurrent drop")
func unusedIndexFinding() throws {
- let result = TableHealthFixture.result(
+ let finding = try #require(findings(
+ .unusedIndex,
columns: ["schema_name", "table_name", "index_name", "index_bytes"],
rows: [["sales", "orders", "orders_note_idx", "8192"]]
- )
- let finding = try #require(provider.findings(for: .unusedIndex, from: result).first)
+ ).first)
#expect(finding.table == "orders")
#expect(finding.objectName == "orders_note_idx")
@@ -65,115 +75,124 @@ struct PostgreSQLTableHealthProviderTests {
@Test("A table without a primary key quotes its name in the preview")
func missingPrimaryKeyQuotesName() throws {
- let result = TableHealthFixture.result(
+ let finding = try #require(findings(
+ .missingPrimaryKey,
columns: ["schema_name", "table_name"],
rows: [["public", #"Odd "Name""#]]
- )
- let finding = try #require(provider.findings(for: .missingPrimaryKey, from: result).first)
+ ).first)
#expect(finding.fixSQL == #"ALTER TABLE "public"."Odd ""Name""" ADD PRIMARY KEY (column_name);"#)
}
@Test("A foreign key's column rows fold into one finding, in key order")
- func foreignKeyColumnsGroup() throws {
- let result = TableHealthFixture.result(
- columns: ["schema_name", "table_name", "constraint_name", "column_name"],
+ func foreignKeyColumnsGroup() {
+ let found = findings(
+ .unindexedForeignKey,
+ columns: ["schema_name", "table_name", "table_kind", "constraint_name", "column_name"],
rows: [
- ["public", "line", "line_order_fk", "order_id"],
- ["public", "line", "line_pair_fk", "y"],
- ["public", "line", "line_pair_fk", "x"],
- ["audit", "line", "line_order_fk", "order_id"]
+ ["public", "line", "r", "line_order_fk", "order_id"],
+ ["public", "line", "r", "line_pair_fk", "y"],
+ ["public", "line", "r", "line_pair_fk", "x"],
+ ["audit", "line", "r", "line_order_fk", "order_id"]
]
)
- let findings = provider.findings(for: .unindexedForeignKey, from: result)
- #expect(findings.map(\.objectName) == ["line_order_fk", "line_pair_fk", "line_order_fk"])
- #expect(findings.map(\.schema) == ["public", "public", "audit"])
- #expect(findings[1].fixSQL == #"CREATE INDEX CONCURRENTLY ON "public"."line" ("y", "x");"#)
- #expect(findings[0].fixSQL == #"CREATE INDEX CONCURRENTLY ON "public"."line" ("order_id");"#)
+ #expect(found.map(\.objectName) == ["line_order_fk", "line_pair_fk", "line_order_fk"])
+ #expect(found.map(\.schema) == ["public", "public", "audit"])
+ #expect(found[0].fixSQL == #"CREATE INDEX CONCURRENTLY ON "public"."line" ("order_id");"#)
+ #expect(found[1].fixSQL == #"CREATE INDEX CONCURRENTLY ON "public"."line" ("y", "x");"#)
+ }
+
+ @Test("A partitioned table's index is not built concurrently, which PostgreSQL refuses there")
+ func partitionedTableIndexIsPlain() throws {
+ let finding = try #require(findings(
+ .unindexedForeignKey,
+ columns: ["schema_name", "table_name", "table_kind", "constraint_name", "column_name"],
+ rows: [["public", "events", "p", "events_user_fk", "user_id"]]
+ ).first)
+ #expect(finding.fixSQL == #"CREATE INDEX ON "public"."events" ("user_id");"#)
}
@Test("Findings of one kind have distinct ids, so the list can select each")
func findingIdsAreDistinct() {
- let result = TableHealthFixture.result(
+ let ids = findings(
+ .unusedIndex,
columns: ["schema_name", "table_name", "index_name", "index_bytes"],
rows: [["a", "t", "i1", "1"], ["a", "t", "i2", "1"], ["b", "t", "i1", "1"]]
- )
- let ids = provider.findings(for: .unusedIndex, from: result).map(\.id)
+ ).map(\.id)
#expect(Set(ids).count == 3)
}
@Test("The note names when statistics were reset, or that they never were")
func statisticsNote() {
- let reset = TableHealthFixture.result(columns: ["stats_reset"], rows: [["2026-10-31 19:48:20+00"]])
- let never = TableHealthFixture.result(columns: ["stats_reset"], rows: [[nil]])
+ #expect(statistics(trackCounts: "on", statsReset: "2026-10-31 19:48:20+00").note?.contains("2026-10-31 19:48:20+00") == true)
+ #expect(statistics(trackCounts: "on", statsReset: nil).note?.contains("never been reset") == true)
+ #expect(provider.statistics(from: [nil], now: Date()) == TableHealthStatistics())
+ }
- #expect(provider.statisticsNote(from: [reset], now: Date())?.contains("2026-10-31 19:48:20+00") == true)
- #expect(provider.statisticsNote(from: [never], now: Date())?.contains("never been reset") == true)
- #expect(provider.statisticsNote(from: [], now: Date()) == nil)
+ @Test("With track_counts off, the checks that read activity counters are skipped")
+ func trackCountsOffSkipsCounterChecks() {
+ let stats = statistics(trackCounts: "off", statsReset: nil)
+ #expect(Set(stats.skippedChecks.keys) == [.bloat, .unusedIndex])
+ #expect(stats.note == nil)
+ }
+
+ @Test("A skipped check runs nothing and says why")
+ func skippedCheckRunsNothing() async throws {
+ var asked: [String] = []
+ let report = try await provider.fetchReport(context: context) { query in
+ asked.append(query)
+ if query == PostgreSQLTableHealthProvider.statisticsQuery {
+ return TableHealthFixture.result(columns: ["track_counts", "stats_reset"], rows: [["off", nil]])
+ }
+ return TableHealthFixture.empty()
+ }
+
+ #expect(!asked.contains(PostgreSQLTableHealthProvider.bloatQuery))
+ #expect(!asked.contains(PostgreSQLTableHealthProvider.unusedIndexQuery))
+ #expect(report.checks[0].outcome == .skipped(statistics(trackCounts: "off", statsReset: nil).skippedChecks[.bloat] ?? ""))
+ #expect(report.checks[2].outcome == .completed)
}
@Test("A failing check reports its error and leaves the other checks standing")
func failingCheckIsolated() async throws {
- let report = try await provider.fetchReport { query in
+ let report = try await provider.fetchReport(context: context) { query in
if query == PostgreSQLTableHealthProvider.bloatQuery { throw QueryFailure() }
if query == PostgreSQLTableHealthProvider.missingPrimaryKeyQuery {
return TableHealthFixture.result(columns: ["schema_name", "table_name"], rows: [["public", "logs"]])
}
- return TableHealthFixture.result(columns: [], rows: [])
+ return TableHealthFixture.empty()
}
+ #expect(report.database == "shop")
#expect(report.checks.map(\.kind) == [.bloat, .unusedIndex, .missingPrimaryKey, .unindexedForeignKey])
- #expect(report.checks[0].error == "permission denied for pg_stat_user_tables")
+ #expect(report.checks[0].outcome == .failed("permission denied for pg_stat_user_tables"))
#expect(report.checks[2].findings.map(\.table) == ["logs"])
#expect(report.findingCount == 1)
}
- @Test("The report names the database the server answered for")
- func reportNamesDatabase() async throws {
- let report = try await provider.fetchReport { query in
- if query == PostgreSQLTableHealthProvider.databaseNameQuery {
- return TableHealthFixture.result(columns: ["database_name"], rows: [["shop"]])
- }
- return TableHealthFixture.result(columns: [], rows: [])
- }
- #expect(report.databaseName == "shop")
- }
-
- @Test("Without the database name the refresh fails, rather than act on a database it cannot name")
- func databaseNameRequired() async {
- await #expect(throws: QueryFailure.self) {
- _ = try await provider.fetchReport { query in
- if query == PostgreSQLTableHealthProvider.databaseNameQuery { throw QueryFailure() }
- return TableHealthFixture.result(columns: [], rows: [])
- }
- }
- }
-
- @Test("A refresh that ends on another database than it began on is discarded")
- func databaseSwitchDiscardsReport() async {
- var answers = ["shop", "archive"]
- await #expect(throws: TableHealthError.self) {
- _ = try await provider.fetchReport { query in
- if query == PostgreSQLTableHealthProvider.databaseNameQuery {
- return TableHealthFixture.result(columns: ["database_name"], rows: [[answers.removeFirst()]])
- }
- return TableHealthFixture.result(columns: [], rows: [])
- }
+ @Test("A statistics query that fails leaves every check to run")
+ func failedStatisticsRunsEveryCheck() async throws {
+ let report = try await provider.fetchReport(context: context) { query in
+ if query == PostgreSQLTableHealthProvider.statisticsQuery { throw QueryFailure() }
+ return TableHealthFixture.empty()
}
+ #expect(report.checks.allSatisfy { $0.outcome == .completed })
+ #expect(report.statisticsNote == nil)
}
@Test("A cancelled refresh stops instead of reporting every check as failed")
func cancellationPropagates() async {
await #expect(throws: CancellationError.self) {
- _ = try await provider.fetchReport { _ in throw CancellationError() }
+ _ = try await provider.fetchReport(context: context) { _ in throw CancellationError() }
}
}
@Test("Every query the report runs only reads")
func queriesOnlyRead() {
- for check in provider.checks {
- let query = provider.query(for: check).trimmingCharacters(in: .whitespacesAndNewlines)
- #expect(query.uppercased().hasPrefix("SELECT"), "\(check) does not start with SELECT")
+ let queries = provider.statisticsQueries + provider.checks.flatMap { provider.queries(for: $0) }
+ for query in queries {
+ let text = query.trimmingCharacters(in: .whitespacesAndNewlines)
+ #expect(text.uppercased().hasPrefix("SELECT"), "\(text.prefix(40)) does not start with SELECT")
}
}
}
diff --git a/apple/TableProTests/Core/TableHealth/TableHealthTestSupport.swift b/apple/TableProTests/Core/TableHealth/TableHealthTestSupport.swift
index 6b237618b3..a621a90117 100644
--- a/apple/TableProTests/Core/TableHealth/TableHealthTestSupport.swift
+++ b/apple/TableProTests/Core/TableHealth/TableHealthTestSupport.swift
@@ -1,8 +1,3 @@
-//
-// TableHealthTestSupport.swift
-// TableProTests
-//
-
import Foundation
@testable import TablePro
import TableProPluginKit
@@ -18,4 +13,16 @@ enum TableHealthFixture {
error: nil
)
}
+
+ static func empty() -> QueryResult {
+ result(columns: [], rows: [])
+ }
+
+ static func postgresContext(database: String = "shop") -> TableHealthQueryContext {
+ TableHealthQueryContext(database: database) { "\"" + $0.replacingOccurrences(of: "\"", with: "\"\"") + "\"" }
+ }
+
+ static func mysqlContext(database: String = "shop") -> TableHealthQueryContext {
+ TableHealthQueryContext(database: database) { "`" + $0.replacingOccurrences(of: "`", with: "``") + "`" }
+ }
}
diff --git a/apple/TableProTests/Models/Query/QueryTabManagerVisibleContainerTests.swift b/apple/TableProTests/Models/Query/QueryTabManagerVisibleContainerTests.swift
index 1f7854550f..10f7c813be 100644
--- a/apple/TableProTests/Models/Query/QueryTabManagerVisibleContainerTests.swift
+++ b/apple/TableProTests/Models/Query/QueryTabManagerVisibleContainerTests.swift
@@ -81,7 +81,7 @@ struct QueryTabManagerVisibleContainerTests {
func serverTabsAreNotBound() {
let manager = makeManager()
- manager.addServerDashboardTab()
+ manager.addSingletonTab(.serverDashboard)
manager.show(container: "tenant_b")
#expect(manager.tabs.first?.tableContext.databaseName == "")
diff --git a/apple/TableProTests/Models/Query/TabStructureVersionTests.swift b/apple/TableProTests/Models/Query/TabStructureVersionTests.swift
index 3ac8257fbf..a6dd7fb888 100644
--- a/apple/TableProTests/Models/Query/TabStructureVersionTests.swift
+++ b/apple/TableProTests/Models/Query/TabStructureVersionTests.swift
@@ -40,15 +40,15 @@ struct TabStructureVersionTests {
#expect(manager.tabStructureVersion == afterFirstAdd)
}
- @Test("addServerDashboardTab() for a new tab bumps once; activating existing does NOT bump")
+ @Test("addSingletonTab() for a new tab bumps once; activating existing does NOT bump")
func addServerDashboardBumpsOnceAndIdempotent() {
let manager = QueryTabManager()
- manager.addServerDashboardTab()
+ manager.addSingletonTab(.serverDashboard)
let afterFirstAdd = manager.tabStructureVersion
#expect(afterFirstAdd == 1)
- manager.addServerDashboardTab()
+ manager.addSingletonTab(.serverDashboard)
#expect(manager.tabStructureVersion == afterFirstAdd)
}
diff --git a/apple/TableProTests/ViewModels/TableHealthViewModelTests.swift b/apple/TableProTests/ViewModels/TableHealthViewModelTests.swift
index b80db31606..8fa65177c8 100644
--- a/apple/TableProTests/ViewModels/TableHealthViewModelTests.swift
+++ b/apple/TableProTests/ViewModels/TableHealthViewModelTests.swift
@@ -1,92 +1,165 @@
-//
-// TableHealthViewModelTests.swift
-// TableProTests
-//
-
import Foundation
@testable import TablePro
import TableProPluginKit
import Testing
-@MainActor
-struct TableHealthViewModelTests {
- private struct Offline: LocalizedError {
- var errorDescription: String? { "No active connection" }
- }
-
- /// Answers the missing primary key check with the given tables and every other check with nothing.
- @MainActor
- private final class FakeServer {
- var tablesWithoutKey: [String] = ["logs"]
- var isOnline = true
- var failsMissingKeyCheck = false
- var database = "shop"
- private(set) var queryCount = 0
-
- func executorSource() throws -> TableHealthViewModel.Execute {
- guard isOnline else { throw Offline() }
- return { [self] query in
- queryCount += 1
- if query == PostgreSQLTableHealthProvider.databaseNameQuery {
- return TableHealthFixture.result(columns: ["database_name"], rows: [[database]])
- }
- guard query == PostgreSQLTableHealthProvider.missingPrimaryKeyQuery else {
- return TableHealthFixture.result(columns: [], rows: [])
- }
- if failsMissingKeyCheck { throw Offline() }
- return TableHealthFixture.result(
- columns: ["schema_name", "table_name"],
- rows: tablesWithoutKey.map { table -> [String?] in ["public", table] }
- )
- }
+private struct Offline: LocalizedError {
+ var errorDescription: String? { "No active connection" }
+}
+
+// Answers the missing primary key check with the given tables and every other read with nothing.
+// The driver runs off the main actor, so the state is locked rather than isolated.
+private final class HealthServer: @unchecked Sendable {
+ private let lock = NSLock()
+ private var _tablesWithoutKey = ["logs"]
+ private var _failsMissingKeyCheck = false
+ private var _queries: [String] = []
+
+ var tablesWithoutKey: [String] {
+ get { lock.withLock { _tablesWithoutKey } }
+ set { lock.withLock { _tablesWithoutKey = newValue } }
+ }
+
+ var failsMissingKeyCheck: Bool {
+ get { lock.withLock { _failsMissingKeyCheck } }
+ set { lock.withLock { _failsMissingKeyCheck = newValue } }
+ }
+
+ var queries: [String] { lock.withLock { _queries } }
+
+ func answer(_ query: String) throws -> PluginQueryResult {
+ let (tables, fails) = lock.withLock { () -> ([String], Bool) in
+ _queries.append(query)
+ return (_tablesWithoutKey, _failsMissingKeyCheck)
}
+ guard query == PostgreSQLTableHealthProvider.missingPrimaryKeyQuery else {
+ return PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0)
+ }
+ if fails { throw Offline() }
+ return PluginQueryResult(
+ columns: ["schema_name", "table_name"],
+ columnTypeNames: ["TEXT", "TEXT"],
+ rows: tables.map { [.text("public"), .text($0)] },
+ rowsAffected: 0,
+ executionTime: 0
+ )
+ }
+}
+
+private final class HealthPluginDriver: PluginDriverStub, @unchecked Sendable {
+ private let server: HealthServer
+
+ init(server: HealthServer) {
+ self.server = server
+ }
+
+ func execute(query: String) async throws -> PluginQueryResult {
+ try server.answer(query)
+ }
+}
+
+@MainActor
+private final class HealthMetadata: ScopedMetadataProviding {
+ let server = HealthServer()
+ let connection = TestFixtures.makeConnection(database: "shop", type: .postgresql)
+ var database = "shop"
+ var isConnected = true
+ var leaseFails = false
+ private(set) var leases: [(scope: DatabaseScope, workload: MetadataConnectionPool.Workload)] = []
+
+ func withMetadataDriver(
+ scope: DatabaseScope,
+ workload: MetadataConnectionPool.Workload,
+ _ body: @Sendable @escaping (DatabaseDriver) async throws -> T
+ ) async throws -> T {
+ leases.append((scope, workload))
+ if leaseFails { throw Offline() }
+ return try await body(PluginDriverAdapter(connection: connection, pluginDriver: HealthPluginDriver(server: server)))
+ }
+
+ func browseScope(for connectionId: UUID) -> DatabaseScope? {
+ guard isConnected else { return nil }
+ return DatabaseScope(connectionId: connectionId, database: database, schema: "public")
}
+}
- private func makeViewModel(_ server: FakeServer, databaseType: DatabaseType = .postgresql) -> TableHealthViewModel {
+@MainActor
+struct TableHealthViewModelTests {
+ private func makeViewModel(
+ _ metadata: HealthMetadata,
+ databaseType: DatabaseType = .postgresql
+ ) -> TableHealthViewModel {
TableHealthViewModel(
- databaseType: databaseType,
+ connectionId: metadata.connection.id,
provider: TableHealthProviderFactory.provider(for: databaseType),
- executorSource: { try server.executorSource() }
+ metadata: metadata
)
}
- @Test("An engine without a provider is not supported and runs nothing")
+ @Test("An engine without a provider is not supported and reads nothing")
func unsupportedEngine() async {
- let server = FakeServer()
- let viewModel = makeViewModel(server, databaseType: .sqlite)
+ let metadata = HealthMetadata()
+ let viewModel = makeViewModel(metadata, databaseType: .sqlite)
await viewModel.refresh()
#expect(!viewModel.isSupported)
#expect(viewModel.report == nil)
- #expect(server.queryCount == 0)
+ #expect(metadata.leases.isEmpty)
+ }
+
+ @Test("A refresh runs every read in one metadata lease on the browsed database, and names it")
+ func oneLeaseOnBrowseScope() async throws {
+ let metadata = HealthMetadata()
+ let viewModel = makeViewModel(metadata)
+
+ await viewModel.refresh()
+
+ let lease = try #require(metadata.leases.first)
+ #expect(metadata.leases.count == 1)
+ #expect(lease.scope.database == "shop")
+ #expect(lease.workload == .bulk)
+ #expect(metadata.server.queries.contains(PostgreSQLTableHealthProvider.missingPrimaryKeyQuery))
+ #expect(viewModel.report?.database == "shop")
+ #expect(viewModel.report?.findingCount == 1)
}
@Test("The first appearance loads and a second appearance does not reload")
func loadsOnce() async {
- let server = FakeServer()
- let viewModel = makeViewModel(server)
+ let metadata = HealthMetadata()
+ let viewModel = makeViewModel(metadata)
await viewModel.loadIfNeeded()
- let afterFirst = server.queryCount
await viewModel.loadIfNeeded()
+ #expect(metadata.leases.count == 1)
#expect(viewModel.report?.findingCount == 1)
- #expect(afterFirst > 0)
- #expect(server.queryCount == afterFirst)
+ }
+
+ @Test("A connection with no database selected asks for one and reads nothing")
+ func serverScopedAsksForDatabase() async {
+ let metadata = HealthMetadata()
+ metadata.database = ""
+ let viewModel = makeViewModel(metadata)
+
+ await viewModel.refresh()
+
+ #expect(viewModel.loadError == TableHealthError.noDatabase.errorDescription)
+ #expect(metadata.leases.isEmpty)
}
@Test("A lost connection says so once, and a later refresh clears it")
func offlineThenOnline() async {
- let server = FakeServer()
- server.isOnline = false
- let viewModel = makeViewModel(server)
+ let metadata = HealthMetadata()
+ metadata.leaseFails = true
+ let viewModel = makeViewModel(metadata)
await viewModel.refresh()
#expect(viewModel.loadError == "No active connection")
#expect(viewModel.report == nil)
+ #expect(metadata.server.queries.isEmpty)
- server.isOnline = true
+ metadata.leaseFails = false
await viewModel.refresh()
#expect(viewModel.loadError == nil)
#expect(viewModel.report?.findingCount == 1)
@@ -94,11 +167,11 @@ struct TableHealthViewModelTests {
@Test("A failed refresh keeps the report it already had")
func failedRefreshKeepsReport() async {
- let server = FakeServer()
- let viewModel = makeViewModel(server)
+ let metadata = HealthMetadata()
+ let viewModel = makeViewModel(metadata)
await viewModel.refresh()
- server.isOnline = false
+ metadata.isConnected = false
await viewModel.refresh()
#expect(viewModel.report?.findingCount == 1)
@@ -107,51 +180,50 @@ struct TableHealthViewModelTests {
@Test("A check that fails on refresh keeps its findings and its selection, under the error")
func failedCheckKeepsFindings() async throws {
- let server = FakeServer()
- let viewModel = makeViewModel(server)
+ let metadata = HealthMetadata()
+ let viewModel = makeViewModel(metadata)
await viewModel.refresh()
let logs = try #require(viewModel.report?.checks.flatMap { $0.findings }.first)
viewModel.selectedFindingId = logs.id
- server.failsMissingKeyCheck = true
+ metadata.server.failsMissingKeyCheck = true
await viewModel.refresh()
let check = try #require(viewModel.report?.checks.first { $0.kind == .missingPrimaryKey })
- #expect(check.error == "No active connection")
+ #expect(check.outcome == .failed("No active connection"))
#expect(check.findings.map { $0.table } == ["logs"])
#expect(viewModel.selectedFinding?.table == "logs")
}
@Test("Findings from another database are not carried over when a check fails")
func otherDatabaseFindingsDropped() async {
- let server = FakeServer()
- let viewModel = makeViewModel(server)
+ let metadata = HealthMetadata()
+ let viewModel = makeViewModel(metadata)
await viewModel.refresh()
- server.database = "archive"
- server.failsMissingKeyCheck = true
+ metadata.database = "archive"
+ metadata.server.failsMissingKeyCheck = true
await viewModel.refresh()
- #expect(viewModel.report?.databaseName == "archive")
+ #expect(viewModel.report?.database == "archive")
#expect(viewModel.report?.findingCount == 0)
#expect(viewModel.selectedFindingId == nil)
}
@Test("A refresh drops the selection when its finding is gone and keeps it otherwise")
func selectionFollowsFindings() async throws {
- let server = FakeServer()
- server.tablesWithoutKey = ["logs", "events"]
- let viewModel = makeViewModel(server)
+ let metadata = HealthMetadata()
+ metadata.server.tablesWithoutKey = ["logs", "events"]
+ let viewModel = makeViewModel(metadata)
await viewModel.refresh()
- let report = try #require(viewModel.report)
- let events = try #require(report.checks.flatMap { $0.findings }.first { $0.table == "events" })
+ let events = try #require(viewModel.report?.checks.flatMap { $0.findings }.first { $0.table == "events" })
viewModel.selectedFindingId = events.id
await viewModel.refresh()
#expect(viewModel.selectedFinding?.table == "events")
- server.tablesWithoutKey = ["logs"]
+ metadata.server.tablesWithoutKey = ["logs"]
await viewModel.refresh()
#expect(viewModel.selectedFindingId == nil)
#expect(viewModel.selectedFinding == nil)
diff --git a/apple/TableProTests/Views/DetailSurfaceThemeTests.swift b/apple/TableProTests/Views/DetailSurfaceThemeTests.swift
index d7d0110b7e..57e684b4e3 100644
--- a/apple/TableProTests/Views/DetailSurfaceThemeTests.swift
+++ b/apple/TableProTests/Views/DetailSurfaceThemeTests.swift
@@ -60,6 +60,7 @@ struct DetailSurfaceThemeTests {
@Test("An object source that is still loading sits on the editor background")
func objectSourceLoading() throws {
let view = ObjectSourceTabView(
+ tabId: UUID(),
connectionId: UUID(),
databaseType: .mysql,
objectRef: DatabaseObjectRef(kind: .view, name: "active_orders", database: "shop"),
diff --git a/apple/TableProTests/Views/Editor/PaneBarThemeTests.swift b/apple/TableProTests/Views/Editor/PaneBarThemeTests.swift
index 26d5b53bec..eca884b02b 100644
--- a/apple/TableProTests/Views/Editor/PaneBarThemeTests.swift
+++ b/apple/TableProTests/Views/Editor/PaneBarThemeTests.swift
@@ -84,6 +84,7 @@ struct PaneBarThemeTests {
@Test("The object source bar keeps its material until the theme colors the window")
func objectSourceBar() throws {
let view = ObjectSourceTabView(
+ tabId: UUID(),
connectionId: UUID(),
databaseType: .mysql,
objectRef: DatabaseObjectRef(kind: .view, name: "active_orders", database: "shop"),
diff --git a/apple/TableProTests/Views/Main/CoordinatorTableListingTests.swift b/apple/TableProTests/Views/Main/CoordinatorTableListingTests.swift
index 6ff06380a2..71fdeb5abb 100644
--- a/apple/TableProTests/Views/Main/CoordinatorTableListingTests.swift
+++ b/apple/TableProTests/Views/Main/CoordinatorTableListingTests.swift
@@ -52,7 +52,7 @@ struct CoordinatorTableListingTests {
let (coordinator, tabManager, _) = makeCoordinator()
defer { coordinator.teardown() }
var refreshed: [UUID] = []
- let subscription = AppEvents.shared.tableListingRefreshRequested.sink { refreshed.append($0) }
+ let subscription = AppEvents.shared.tabRefreshRequested.sink { refreshed.append($0) }
defer { subscription.cancel() }
coordinator.openTableListing()
@@ -72,7 +72,7 @@ struct CoordinatorTableListingTests {
coordinator.openTableListing()
let id = try #require(tabManager.selectedTabId)
var refreshed: [UUID] = []
- let subscription = AppEvents.shared.tableListingRefreshRequested.sink { refreshed.append($0) }
+ let subscription = AppEvents.shared.tabRefreshRequested.sink { refreshed.append($0) }
defer { subscription.cancel() }
coordinator.handleRefresh(hasPendingTableOps: false, onDiscard: {})
diff --git a/apple/TableProTests/Views/Main/MainContentCoordinatorRefreshTests.swift b/apple/TableProTests/Views/Main/MainContentCoordinatorRefreshTests.swift
index b1056c17ac..cac01ec4dd 100644
--- a/apple/TableProTests/Views/Main/MainContentCoordinatorRefreshTests.swift
+++ b/apple/TableProTests/Views/Main/MainContentCoordinatorRefreshTests.swift
@@ -370,11 +370,45 @@ struct MainContentCoordinatorRefreshTests {
tabManager.addVersionHistoryTab(subject: .savedQuery(id: UUID()), title: "History: Revenue")
let tabId = tabManager.selectedTabId
var requested: [UUID] = []
- let subscription = AppEvents.shared.versionHistoryRefreshRequested.sink { requested.append($0) }
+ let subscription = AppEvents.shared.tabRefreshRequested.sink { requested.append($0) }
defer { subscription.cancel() }
coordinator.handleRefresh(hasPendingTableOps: false, onDiscard: {})
#expect(requested == [tabId].compactMap { $0 })
}
+
+ @Test("Refresh on a tab that loads its own content asks that tab to reload")
+ func refreshOnSelfLoadingTabsAsksThem() {
+ let (coordinator, tabManager) = makeCoordinator()
+ var requested: [UUID] = []
+ let subscription = AppEvents.shared.tabRefreshRequested.sink { requested.append($0) }
+ defer { subscription.cancel() }
+
+ var expected: [UUID] = []
+ for tabType in [TabType.serverDashboard, .insights, .tableHealth] {
+ tabManager.addSingletonTab(tabType)
+ expected += [tabManager.selectedTabId].compactMap { $0 }
+ coordinator.handleRefresh(hasPendingTableOps: false, onDiscard: {})
+ }
+ tabManager.addObjectSourceTab(objectRef: DatabaseObjectRef(kind: .view, name: "active_orders", database: "shop"))
+ expected += [tabManager.selectedTabId].compactMap { $0 }
+ coordinator.handleRefresh(hasPendingTableOps: false, onDiscard: {})
+
+ #expect(requested == expected)
+ }
+
+ @Test("Refresh on Users & Roles reloads the principals through the tab's own handler")
+ func refreshOnUsersRolesReloads() {
+ let (coordinator, tabManager) = makeCoordinator()
+ tabManager.addSingletonTab(.usersRoles)
+ let actions = UsersRolesActionHandler()
+ var reloads = 0
+ actions.refresh = { reloads += 1 }
+ coordinator.usersRolesActions = actions
+
+ coordinator.handleRefresh(hasPendingTableOps: false, onDiscard: {})
+
+ #expect(reloads == 1)
+ }
}
diff --git a/apple/changelog.d/3958.added.md b/apple/changelog.d/3958.added.md
index d968de9799..7fc59675af 100644
--- a/apple/changelog.d/3958.added.md
+++ b/apple/changelog.d/3958.added.md
@@ -1 +1 @@
-Table Health lists bloated tables, unused and redundant indexes, and missing keys on PostgreSQL and MySQL.
+Table Health lists bloated tables, unused and redundant indexes, and missing keys in a PostgreSQL or MySQL database.
diff --git a/apple/changelog.d/3958.fixed.md b/apple/changelog.d/3958.fixed.md
new file mode 100644
index 0000000000..d5a95dfa3f
--- /dev/null
+++ b/apple/changelog.d/3958.fixed.md
@@ -0,0 +1 @@
+Refresh did nothing on the Server Dashboard, Query Insights, Users & Roles and object source tabs.
diff --git a/apple/scripts/probes/check-table-health-catalogs.sh b/apple/scripts/probes/check-table-health-catalogs.sh
index a740f68df4..3c51bfa627 100755
--- a/apple/scripts/probes/check-table-health-catalogs.sh
+++ b/apple/scripts/probes/check-table-health-catalogs.sh
@@ -2,12 +2,11 @@
#
# Check the Table Health queries against real PostgreSQL, MySQL and MariaDB servers.
#
-# The report reads pg_stat_user_tables, pg_stat_user_indexes and the catalogs on PostgreSQL, and the
-# performance_schema and information_schema on MySQL and MariaDB. Each query is read out of the provider
-# source, so the probe and the app cannot drift. The probe builds a database with one table per
-# finding and one per near miss (a unique index, a foreign key an index covers in another order, a
-# partial index), runs every query, checks which objects come back, then runs the fix the report
-# would preview and checks the finding goes away.
+# Each query is read out of the provider source, so the probe and the app cannot drift. The probe
+# builds a database with one object per finding and one per near miss, runs every query, checks
+# which objects come back, then runs the fix the report would preview and checks the finding goes.
+# The MySQL checks that combine rows in Swift (unused minus unique and foreign key indexes,
+# redundant indexes) are pinned by MySQLTableHealthProviderTests; here, the rows they read.
#
# Usage:
# scripts/probes/check-table-health-catalogs.sh postgres
@@ -24,24 +23,28 @@ PORT="${3:-}"
USER_NAME="${4:-}"
SOURCES="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/TablePro/Core/TableHealth"
DATABASE="tablepro_health_check"
+OTHER_DATABASE="tablepro_health_other"
FAILURES=0
-# Prints the body of `static let = """ ... """` from a provider file.
-swift_literal() {
- awk -v name="$2" '
+# Prints the body of `static let = """ ... """`, or the text of `static let = "..."`.
+query_of() {
+ local text
+ text="$(awk -v name="$2" '
$0 ~ "static let " name " = \"\"\"$" { inside = 1; next }
inside && $0 ~ /^[[:space:]]*"""[[:space:]]*$/ { exit }
inside { print }
- ' "$1"
-}
-
-swift_line_literal() {
- sed -n "s/^[[:space:]]*static let $2 = \"\\(.*\\)\"\$/\\1/p" "$1"
+ ' "$1")"
+ [ -n "$text" ] || text="$(sed -n "s/^[[:space:]]*static let $2 = \"\\(.*\\)\"\$/\\1/p" "$1")"
+ [ -n "$text" ] || {
+ echo "FAIL: $2 not found in $1" >&2
+ exit 1
+ }
+ printf '%s' "$text"
}
expect() {
- local label="$1" output="$2" needle="$3" wanted="$4"
- if grep -qF -- "$needle" <<< "$output"; then found=yes; else found=no; fi
+ local label="$1" output="$2" needle="$3" wanted="$4" found=no
+ grep -qF -- "$needle" <<< "$output" && found=yes
if [ "$found" = "$wanted" ]; then
echo "ok $label"
else
@@ -50,39 +53,31 @@ expect() {
fi
}
-query_of() {
- local text
- text="$(swift_literal "$1" "$2")"
- [ -n "$text" ] || {
- echo "FAIL: $2 not found in $1" >&2
- exit 1
- }
- printf '%s' "$text"
-}
-
check_postgres() {
local source="$SOURCES/PostgreSQLTableHealthProvider.swift"
command -v psql > /dev/null || { echo "psql not found" >&2; exit 3; }
export PGOPTIONS="-c client_min_messages=warning"
pg() { psql -X -q -A -t -F '|' -h "$HOST" -p "$PORT" -U "$USER_NAME" -v ON_ERROR_STOP=1 "$@"; }
pg -d postgres -c "SELECT 1" > /dev/null 2>&1 || { echo "no PostgreSQL at $HOST:$PORT" >&2; exit 3; }
+ local version
+ version="$(pg -d postgres -c 'SHOW server_version_num')"
echo "PostgreSQL $(pg -d postgres -c 'SHOW server_version') at $HOST:$PORT"
pg -d postgres -c "DROP DATABASE IF EXISTS $DATABASE" > /dev/null
pg -d postgres -c "CREATE DATABASE $DATABASE" > /dev/null
- trap 'psql -X -q -h "$HOST" -p "$PORT" -U "$USER_NAME" -d postgres -c "DROP DATABASE IF EXISTS '"$DATABASE"'" > /dev/null 2>&1' EXIT
+ trap 'psql -X -q -h "$HOST" -p "$PORT" -U "$USER_NAME" -d postgres -c "DROP DATABASE IF EXISTS '"$DATABASE"' WITH (FORCE)" > /dev/null 2>&1 || psql -X -q -h "$HOST" -p "$PORT" -U "$USER_NAME" -d postgres -c "DROP DATABASE IF EXISTS '"$DATABASE"'" > /dev/null 2>&1' EXIT
pg -d "$DATABASE" > /dev/null <<'SQL'
CREATE TABLE parent (id int PRIMARY KEY, code int UNIQUE);
CREATE TABLE child_unindexed (id int PRIMARY KEY, parent_id int REFERENCES parent (id));
CREATE TABLE child_indexed (id int PRIMARY KEY, parent_id int REFERENCES parent (id));
-CREATE INDEX ON child_indexed (parent_id);
+CREATE INDEX child_indexed_parent_idx ON child_indexed (parent_id);
CREATE TABLE pair (x int, y int, PRIMARY KEY (x, y));
CREATE TABLE child_pair (id int PRIMARY KEY, x int, y int, CONSTRAINT child_pair_fk FOREIGN KEY (x, y) REFERENCES pair (x, y));
-CREATE INDEX ON child_pair (y, x);
+CREATE INDEX child_pair_yx_idx ON child_pair (y, x);
CREATE TABLE child_pair_unindexed (id int PRIMARY KEY, x int, y int, CONSTRAINT child_pair_unindexed_fk FOREIGN KEY (y, x) REFERENCES pair (y, x));
CREATE TABLE child_partial (id int PRIMARY KEY, parent_id int REFERENCES parent (id));
-CREATE INDEX ON child_partial (parent_id) WHERE parent_id > 0;
+CREATE INDEX child_partial_idx ON child_partial (parent_id) WHERE parent_id > 0;
CREATE TABLE child_second (id int PRIMARY KEY, other int, parent_id int REFERENCES parent (id));
CREATE INDEX ON child_second (other, parent_id);
CREATE TABLE child_include (id int PRIMARY KEY, x int, y int, CONSTRAINT child_include_fk FOREIGN KEY (x, y) REFERENCES pair (x, y));
@@ -90,6 +85,8 @@ CREATE TABLE no_pk (v int);
CREATE INDEX no_pk_unused_idx ON no_pk (v);
CREATE UNIQUE INDEX no_pk_unique_idx ON no_pk ((v + 1));
CREATE TABLE "Odd ""Name""" (v int);
+CREATE MATERIALIZED VIEW stock AS SELECT g AS v FROM generate_series(1, 10) g;
+CREATE INDEX stock_v_idx ON stock (v);
CREATE TABLE bloated (id int PRIMARY KEY, v int) WITH (autovacuum_enabled = false);
INSERT INTO bloated SELECT g, g FROM generate_series(1, 5000) g;
DELETE FROM bloated WHERE id <= 3000;
@@ -97,9 +94,34 @@ CREATE TABLE healthy (id int PRIMARY KEY) WITH (autovacuum_enabled = false);
INSERT INTO healthy SELECT g FROM generate_series(1, 5000) g;
DELETE FROM healthy WHERE id <= 10;
SQL
+ if [ "$version" -ge 110000 ]; then
+ pg -d "$DATABASE" > /dev/null <<'SQL'
+CREATE TABLE events (at date, kind text, parent_id int REFERENCES parent (id)) PARTITION BY RANGE (at);
+CREATE TABLE events_2026 PARTITION OF events FOR VALUES FROM ('2026-01-01') TO ('2027-01-01');
+CREATE TABLE events_2027 PARTITION OF events FOR VALUES FROM ('2027-01-01') TO ('2028-01-01');
+CREATE INDEX events_kind_idx ON events (kind);
+SQL
+ fi
+
+ # Another session's temporary table lives in its own pg_temp schema while that session is open.
+ psql -X -q -h "$HOST" -p "$PORT" -U "$USER_NAME" -d "$DATABASE" \
+ -c "CREATE TEMP TABLE probe_temp (v int)" -c "CREATE INDEX probe_temp_idx ON probe_temp (v)" -c "SELECT pg_sleep(30)" \
+ > /dev/null 2>&1 &
+ local holder=$! temp_ready=0
+ for _ in $(seq 1 20); do
+ [ "$(pg -d "$DATABASE" -c "SELECT count(*) FROM pg_class WHERE relname = 'probe_temp_idx'")" = "1" ] && { temp_ready=1; break; }
+ sleep 0.5
+ done
+ [ "$temp_ready" = 1 ] || echo "note another session's temporary table did not appear; its checks are vacuous" >&2
+
+ local stats
+ stats="$(pg -d "$DATABASE" -c "$(query_of "$source" statisticsQuery)")"
+ expect "statistics read track_counts" "$stats" "on|" yes
+ stats="$(PGOPTIONS="$PGOPTIONS -c track_counts=off" pg -d "$DATABASE" -c "$(query_of "$source" statisticsQuery)")"
+ expect "statistics see track_counts off" "$stats" "off|" yes
local bloat=""
- for _ in 1 2 3 4 5 6 7 8 9 10; do
+ for _ in $(seq 1 10); do
bloat="$(pg -d "$DATABASE" -c "$(query_of "$source" bloatQuery)")"
grep -qF "bloated" <<< "$bloat" && break
sleep 1
@@ -110,18 +132,16 @@ SQL
local unused
unused="$(pg -d "$DATABASE" -c "$(query_of "$source" unusedIndexQuery)")"
expect "unused index lists a plain index" "$unused" "|no_pk_unused_idx|" yes
+ expect "unused index lists a partial index, which serves no foreign key" "$unused" "|child_partial_idx|" yes
expect "unused index skips a unique index" "$unused" "no_pk_unique_idx" no
expect "unused index skips a primary key" "$unused" "parent_pkey" no
expect "unused index skips a unique constraint" "$unused" "parent_code_key" no
- if [ "$(pg -d "$DATABASE" -c 'SHOW server_version_num')" -ge 110000 ]; then
- pg -d "$DATABASE" > /dev/null <<'PART'
-CREATE TABLE events (at date, kind text) PARTITION BY RANGE (at);
-CREATE TABLE events_2026 PARTITION OF events FOR VALUES FROM ('2026-01-01') TO ('2027-01-01');
-CREATE INDEX events_kind_idx ON events (kind);
-PART
- unused="$(pg -d "$DATABASE" -c "$(query_of "$source" unusedIndexQuery)")"
+ expect "unused index skips an index behind a foreign key" "$unused" "child_indexed_parent_idx" no
+ expect "unused index skips an index behind a key in another order" "$unused" "child_pair_yx_idx" no
+ expect "unused index skips a materialized view's index" "$unused" "stock_v_idx" no
+ expect "unused index skips another session's temporary table" "$unused" "probe_temp_idx" no
+ if [ "$version" -ge 110000 ]; then
expect "unused index skips a partition index its parent owns" "$unused" "events_2026_kind_idx" no
- pg -d "$DATABASE" -c 'DROP TABLE events' > /dev/null
fi
local nopk
@@ -130,15 +150,23 @@ PART
expect "missing primary key lists a quoted name" "$nopk" 'public|Odd "Name"' yes
expect "missing primary key skips a keyed table" "$nopk" "|parent" no
expect "missing primary key skips the system catalogs" "$nopk" "pg_catalog" no
+ expect "missing primary key skips another session's temporary table" "$nopk" "probe_temp" no
+ if [ "$version" -ge 110000 ]; then
+ expect "missing primary key lists a partitioned table" "$nopk" "public|events" yes
+ expect "missing primary key skips its partitions" "$nopk" "events_2026" no
+ fi
local fk
fk="$(pg -d "$DATABASE" -c "$(query_of "$source" unindexedForeignKeyQuery)")"
- expect "foreign key without an index" "$fk" "child_unindexed_parent_id_fkey|parent_id" yes
+ expect "foreign key without an index" "$fk" "child_unindexed|r|child_unindexed_parent_id_fkey|parent_id" yes
expect "foreign key behind a partial index" "$fk" "child_partial_parent_id_fkey" yes
expect "foreign key on an index's second column" "$fk" "child_second_parent_id_fkey" yes
expect "composite key columns in key order" "$fk" "child_pair_unindexed_fk|y" yes
expect "foreign key with an index" "$fk" "child_indexed_parent_id_fkey" no
- if [ "$(pg -d "$DATABASE" -c 'SHOW server_version_num')" -ge 110000 ]; then
+ expect "composite key an index covers in another order" "$fk" "child_pair_fk|" no
+ if [ "$version" -ge 110000 ]; then
+ expect "a partitioned table's key names the table kind" "$fk" "|events|p|" yes
+ expect "a partition's cloned key is left to its parent" "$fk" "events_2026" no
pg -d "$DATABASE" -c 'CREATE INDEX ON child_include (x) INCLUDE (y)' > /dev/null
fk="$(pg -d "$DATABASE" -c "$(query_of "$source" unindexedForeignKeyQuery)")"
expect "an INCLUDE column does not cover a key column" "$fk" "child_include_fk" yes
@@ -146,25 +174,22 @@ PART
fk="$(pg -d "$DATABASE" -c "$(query_of "$source" unindexedForeignKeyQuery)")"
expect "key columns in an index cover the key" "$fk" "child_include_fk" no
fi
- expect "composite key an index covers in another order" "$fk" "child_pair_fk|" no
-
- local name
- name="$(pg -d "$DATABASE" -c "$(swift_line_literal "$source" databaseNameQuery)")"
- expect "the database name query names this database" "$name" "$DATABASE" yes
-
- local stats
- stats="$(pg -d "$DATABASE" -c "$(query_of "$source" statisticsResetQuery)")"
- echo "ok statistics reset query ran: '${stats}'"
# The fixes exactly as PostgreSQLTableHealthProviderTests pins them.
pg -d "$DATABASE" -c 'CREATE INDEX CONCURRENTLY ON "public"."child_unindexed" ("parent_id");' > /dev/null
pg -d "$DATABASE" -c 'VACUUM (ANALYZE) "public"."bloated";' > /dev/null
pg -d "$DATABASE" -c 'DROP INDEX CONCURRENTLY "public"."no_pk_unused_idx";' > /dev/null
pg -d "$DATABASE" -c 'ALTER TABLE "public"."no_pk" ADD PRIMARY KEY (v);' > /dev/null
+ if [ "$version" -ge 110000 ]; then
+ pg -d "$DATABASE" -c 'CREATE INDEX ON "public"."events" ("parent_id");' > /dev/null
+ fi
fk="$(pg -d "$DATABASE" -c "$(query_of "$source" unindexedForeignKeyQuery)")"
expect "the previewed index clears its finding" "$fk" "child_unindexed_parent_id_fkey" no
- for _ in 1 2 3 4 5 6 7 8 9 10; do
+ if [ "$version" -ge 110000 ]; then
+ expect "the previewed partitioned index clears its finding" "$fk" "|events|" no
+ fi
+ for _ in $(seq 1 10); do
bloat="$(pg -d "$DATABASE" -c "$(query_of "$source" bloatQuery)")"
grep -qF "bloated" <<< "$bloat" || break
sleep 1
@@ -174,6 +199,9 @@ PART
expect "the previewed drop clears its finding" "$unused" "no_pk_unused_idx" no
nopk="$(pg -d "$DATABASE" -c "$(query_of "$source" missingPrimaryKeyQuery)")"
expect "the previewed primary key clears its finding" "$nopk" "public|no_pk" no
+
+ kill "$holder" 2> /dev/null
+ wait "$holder" 2> /dev/null
}
check_mysql() {
@@ -181,64 +209,91 @@ check_mysql() {
command -v mysql > /dev/null || { echo "mysql not found" >&2; exit 3; }
# MariaDB's client warns on stderr about a passwordless certificate check, which would land in captured output.
local tls=()
- mysql --help 2>/dev/null | grep -q -- "--ssl-verify-server-cert" && tls=(--disable-ssl-verify-server-cert)
- my() { mysql --protocol=TCP -h "$HOST" -P "$PORT" -u "$USER_NAME" "${tls[@]}" -N -B "$@"; }
- my -e "SELECT 1" > /dev/null 2>&1 || { echo "no MySQL at $HOST:$PORT" >&2; exit 3; }
+ mysql --help 2> /dev/null | grep -q -- "--ssl-verify-server-cert" && tls=(--disable-ssl-verify-server-cert)
+ # bash 3.2 calls an empty "${tls[@]}" unbound under set -u.
+ my() { mysql --protocol=TCP -h "$HOST" -P "$PORT" -u "$USER_NAME" ${tls[@]+"${tls[@]}"} -N -B "$@"; }
+ my -e "SELECT 1" > /dev/null || { echo "no MySQL at $HOST:$PORT" >&2; exit 3; }
echo "$(my -e 'SELECT VERSION()') at $HOST:$PORT"
- my -e "DROP DATABASE IF EXISTS $DATABASE; CREATE DATABASE $DATABASE"
- trap 'mysql --protocol=TCP -h "$HOST" -P "$PORT" -u "$USER_NAME" -e "DROP DATABASE IF EXISTS '"$DATABASE"'" > /dev/null 2>&1' EXIT
+ my -e "DROP DATABASE IF EXISTS $DATABASE; CREATE DATABASE $DATABASE; DROP DATABASE IF EXISTS $OTHER_DATABASE; CREATE DATABASE $OTHER_DATABASE"
+ trap 'mysql --protocol=TCP -h "$HOST" -P "$PORT" -u "$USER_NAME" -e "DROP DATABASE IF EXISTS '"$DATABASE"'; DROP DATABASE IF EXISTS '"$OTHER_DATABASE"'" > /dev/null 2>&1' EXIT
- my "$DATABASE" > /dev/null <<'SQL'
+ my "$OTHER_DATABASE" -e "CREATE TABLE other_no_pk (v INT, KEY other_v_idx (v)); INSERT INTO other_no_pk VALUES (1)"
+ my "$DATABASE" > /dev/null <<'SQL' || exit 1
CREATE TABLE parent (id INT PRIMARY KEY);
+CREATE TABLE child (id INT PRIMARY KEY, parent_id INT, note INT, CONSTRAINT child_fk FOREIGN KEY (parent_id) REFERENCES parent (id), KEY child_note_idx (note));
CREATE TABLE no_pk (a INT, b INT, KEY no_pk_a_idx (a), KEY no_pk_ab_idx (a, b), UNIQUE KEY no_pk_b_uniq (b));
CREATE TABLE `odd``name` (v INT);
CREATE TABLE healthy (id INT PRIMARY KEY, v INT, KEY healthy_v_idx (v));
+INSERT INTO parent VALUES (1);
+INSERT INTO child VALUES (1, 1, 1);
INSERT INTO healthy VALUES (1, 1), (2, 2), (3, 3);
INSERT INTO no_pk VALUES (1, 1), (2, 2);
SELECT COUNT(*) FROM healthy FORCE INDEX (healthy_v_idx) WHERE v = 2;
SQL
- [ $? -eq 0 ] || exit 1
local ps
- ps="$(my -e "$(swift_line_literal "$source" performanceSchemaQuery)")"
+ ps="$(my "$DATABASE" -e "$(query_of "$source" performanceSchemaQuery)")"
echo "ok performance_schema = $ps"
local uptime
- uptime="$(my -e "$(swift_line_literal "$source" uptimeQuery)")"
+ uptime="$(my "$DATABASE" -e "$(query_of "$source" uptimeQuery)")"
expect "uptime reads as Uptime and a value" "$uptime" "Uptime" yes
- local unused
- unused="$(my -e "$(query_of "$source" unusedIndexQuery)")"
+ local counting
+ counting="$(my "$DATABASE" -e "$(query_of "$source" usageCountingQuery)")"
if [ "$ps" = "1" ]; then
- expect "unused index lists a plain index" "$unused" "no_pk_a_idx" yes
- expect "unused index skips a unique index" "$unused" "no_pk_b_uniq" no
- expect "unused index skips an index a query read" "$unused" "healthy_v_idx" no
+ expect "the handler instrument and global consumer read as enabled" "$counting" "YES YES " yes
+ expect "this database has counted table I/O" "$counting" " 0" no
+ my -e "UPDATE performance_schema.setup_instruments SET ENABLED = 'NO' WHERE NAME = 'wait/io/table/sql/handler'"
+ counting="$(my "$DATABASE" -e "$(query_of "$source" usageCountingQuery)")"
+ my -e "UPDATE performance_schema.setup_instruments SET ENABLED = 'YES' WHERE NAME = 'wait/io/table/sql/handler'"
+ expect "a disabled handler instrument reads as NO" "$counting" "NO YES " yes
else
- echo "note performance_schema is off, so the unused index list is empty: '$unused'"
+ echo "note performance_schema is off, so the unused index check is skipped: '$counting'"
+ fi
+
+ local unused
+ unused="$(my "$DATABASE" -e "$(query_of "$source" unusedIndexQuery)")"
+ if [ "$ps" = "1" ]; then
+ expect "unused candidates list a plain index" "$unused" "no_pk no_pk_a_idx" yes
+ expect "unused candidates list the foreign key's own index, which InnoDB reads uncounted" "$unused" "child child_fk" yes
+ expect "unused candidates skip an index a query read" "$unused" "healthy_v_idx" no
+ expect "unused candidates skip another database" "$unused" "other_v_idx" no
+ fi
+ local drop_fk_index
+ drop_fk_index="$(my "$DATABASE" -e 'ALTER TABLE child DROP INDEX child_fk' 2>&1)"
+ expect "InnoDB refuses to drop the index a foreign key needs" "$drop_fk_index" "1553" yes
+
+ local indexes
+ indexes="$(my "$DATABASE" --column-names -e "$(query_of "$source" indexQuery)")"
+ expect "index rows carry NON_UNIQUE, INDEX_TYPE and SUB_PART by name" "$indexes" "NON_UNIQUE" yes
+ expect "index rows list a composite index" "$indexes" "no_pk_ab_idx" yes
+ expect "index rows skip another database" "$indexes" "other_v_idx" no
+ if my -e "SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = 'information_schema' AND TABLE_NAME = 'STATISTICS' AND COLUMN_NAME = 'IS_VISIBLE'" | grep -q 1; then
+ expect "MySQL index rows carry IS_VISIBLE" "$indexes" "IS_VISIBLE" yes
+ fi
+ if my -e "SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = 'information_schema' AND TABLE_NAME = 'STATISTICS' AND COLUMN_NAME = 'IGNORED'" | grep -q 1; then
+ expect "MariaDB index rows carry IGNORED" "$indexes" "IGNORED" yes
fi
- # The prefix comparison runs in Swift (MySQLTableHealthProviderTests); here the rows it reads.
- local redundant
- redundant="$(my -e "$(query_of "$source" redundantIndexQuery)")"
- expect "index rows carry the first key column" "$redundant" "$DATABASE no_pk no_pk_ab_idx 1 BTREE a " yes
- expect "index rows carry the second key column" "$redundant" "$DATABASE no_pk no_pk_ab_idx 1 BTREE b " yes
- expect "index rows mark a unique index" "$redundant" "$DATABASE no_pk no_pk_b_uniq 0 BTREE b " yes
- expect "index rows skip the system schemas" "$redundant" "mysql " no
+ local keys
+ keys="$(my "$DATABASE" -e "$(query_of "$source" foreignKeyQuery)")"
+ expect "foreign key rows name the table, key and column" "$keys" "child child_fk parent_id" yes
local nopk
- nopk="$(my -e "$(query_of "$source" missingPrimaryKeyQuery)")"
- expect "missing primary key lists a heap" "$nopk" "$DATABASE no_pk" yes
+ nopk="$(my "$DATABASE" -e "$(query_of "$source" missingPrimaryKeyQuery)")"
+ expect "missing primary key lists a heap" "$nopk" "no_pk" yes
expect "missing primary key lists a quoted name" "$nopk" "odd\`name" yes
- expect "missing primary key skips a keyed table" "$nopk" " parent" no
- expect "missing primary key skips the system schemas" "$nopk" "mysql " no
+ expect "missing primary key skips a keyed table" "$nopk" "parent" no
+ expect "missing primary key skips another database" "$nopk" "other_no_pk" no
# The fixes exactly as MySQLTableHealthProviderTests pins them.
my -e "ALTER TABLE \`$DATABASE\`.\`no_pk\` DROP INDEX \`no_pk_a_idx\`;"
my -e "ALTER TABLE \`$DATABASE\`.\`odd\`\`name\` ADD PRIMARY KEY (v);"
- nopk="$(my -e "$(query_of "$source" missingPrimaryKeyQuery)")"
+ nopk="$(my "$DATABASE" -e "$(query_of "$source" missingPrimaryKeyQuery)")"
expect "the previewed primary key clears its finding" "$nopk" "odd\`name" no
- redundant="$(my -e "$(query_of "$source" redundantIndexQuery)")"
- expect "the previewed drop clears its finding" "$redundant" "no_pk_a_idx" no
+ indexes="$(my "$DATABASE" -e "$(query_of "$source" indexQuery)")"
+ expect "the previewed drop clears its finding" "$indexes" "no_pk_a_idx" no
}
case "$ENGINE" in
From 93c5dca0f845d55bc556a0797c272b5b1196e398 Mon Sep 17 00:00:00 2001
From: Ngo Quoc Dat
Date: Sun, 1 Nov 2026 06:08:30 +0700
Subject: [PATCH 08/10] fix(table-health): keep the first load alive across
pane switches and skip uncounted or referenced MySQL indexes
---
.../MySQLTableHealthProvider.swift | 54 ++++++++++++++-----
.../ViewModels/TableHealthViewModel.swift | 23 ++++++--
.../Views/TableHealth/TableHealthView.swift | 4 +-
.../MySQLTableHealthProviderTests.swift | 43 ++++++++++++---
.../TableHealthViewModelTests.swift | 27 ++++++++++
.../probes/check-table-health-catalogs.sh | 11 +++-
6 files changed, 132 insertions(+), 30 deletions(-)
diff --git a/apple/TablePro/Core/TableHealth/MySQLTableHealthProvider.swift b/apple/TablePro/Core/TableHealth/MySQLTableHealthProvider.swift
index 51be58970b..0197a0e7ed 100644
--- a/apple/TablePro/Core/TableHealth/MySQLTableHealthProvider.swift
+++ b/apple/TablePro/Core/TableHealth/MySQLTableHealthProvider.swift
@@ -20,7 +20,8 @@ struct MySQLTableHealthProvider: TableHealthProvider {
WHERE t.OBJECT_SCHEMA = DATABASE() AND t.COUNT_STAR > 0) AS counted_tables
"""
- // The table sys.schema_unused_indexes reads; MariaDB before 10.6 has no sys schema.
+ // The table sys.schema_unused_indexes reads; MariaDB before 10.6 has no sys schema. A table
+ // setup_objects leaves out keeps its index rows at zero, so only a table with counted I/O counts.
static let unusedIndexQuery = """
SELECT u.OBJECT_NAME AS table_name,
u.INDEX_NAME AS index_name
@@ -29,6 +30,13 @@ struct MySQLTableHealthProvider: TableHealthProvider {
AND u.INDEX_NAME IS NOT NULL
AND u.INDEX_NAME <> 'PRIMARY'
AND u.COUNT_STAR = 0
+ AND EXISTS (
+ SELECT 1 FROM performance_schema.table_io_waits_summary_by_table t
+ WHERE t.OBJECT_TYPE = u.OBJECT_TYPE
+ AND t.OBJECT_SCHEMA = u.OBJECT_SCHEMA
+ AND t.OBJECT_NAME = u.OBJECT_NAME
+ AND t.COUNT_STAR > 0
+ )
ORDER BY u.OBJECT_NAME, u.INDEX_NAME
"""
@@ -40,10 +48,14 @@ struct MySQLTableHealthProvider: TableHealthProvider {
ORDER BY TABLE_NAME, INDEX_NAME, SEQ_IN_INDEX
"""
+ // Both ends: InnoDB checks a key through an index on each side, and MariaDB lets the referenced
+ // one be non-unique. A key from another database is not read: on MariaDB that scans every database.
static let foreignKeyQuery = """
SELECT k.TABLE_NAME AS table_name,
k.CONSTRAINT_NAME AS constraint_name,
- k.COLUMN_NAME AS column_name
+ k.COLUMN_NAME AS column_name,
+ CASE WHEN k.REFERENCED_TABLE_SCHEMA = DATABASE() THEN k.REFERENCED_TABLE_NAME END AS referenced_table_name,
+ k.REFERENCED_COLUMN_NAME AS referenced_column_name
FROM information_schema.KEY_COLUMN_USAGE k
WHERE k.TABLE_SCHEMA = DATABASE()
AND k.REFERENCED_TABLE_NAME IS NOT NULL
@@ -134,8 +146,8 @@ struct MySQLTableHealthProvider: TableHealthProvider {
}
}
- // A unique index enforces something no read counts. InnoDB checks a foreign key through the
- // index that leads with its columns without counting a read, and refuses to drop the last one.
+ // A unique index enforces something no read counts. InnoDB checks a foreign key through an
+ // index that leads with its columns on either end without counting a read, and refuses to drop the last one.
private func unusedIndexFindings(
candidates: [TableHealthRow],
tables: [MySQLIndexShape],
@@ -264,21 +276,35 @@ struct MySQLIndexShape {
return tables
}
- // Each table's foreign keys, as their columns in key order.
+ // Per table, the columns each foreign key reads through it, on the referencing or the referenced end, in key order.
static func foreignKeys(from rows: [TableHealthRow]) -> [String: [[String]]] {
- var keys: [String: [[String]]] = [:]
- var previous: (table: String, constraint: String)?
+ struct Key {
+ let table: String
+ let constraint: String
+ let referencedTable: String
+ var columns: [String]
+ var referencedColumns: [String]
+ }
+ var keys: [Key] = []
for row in rows {
let table = row["table_name"], constraint = row["constraint_name"]
- var tableKeys = keys[table] ?? []
- if previous?.table == table, previous?.constraint == constraint, !tableKeys.isEmpty {
- tableKeys[tableKeys.count - 1].append(row["column_name"])
+ if let last = keys.last, last.table == table, last.constraint == constraint {
+ keys[keys.count - 1].columns.append(row["column_name"])
+ keys[keys.count - 1].referencedColumns.append(row["referenced_column_name"])
} else {
- tableKeys.append([row["column_name"]])
+ keys.append(Key(
+ table: table, constraint: constraint, referencedTable: row["referenced_table_name"],
+ columns: [row["column_name"]], referencedColumns: [row["referenced_column_name"]]
+ ))
+ }
+ }
+ var byTable: [String: [[String]]] = [:]
+ for key in keys {
+ byTable[key.table, default: []].append(key.columns)
+ if !key.referencedTable.isEmpty {
+ byTable[key.referencedTable, default: []].append(key.referencedColumns)
}
- keys[table] = tableKeys
- previous = (table, constraint)
}
- return keys
+ return byTable
}
}
diff --git a/apple/TablePro/ViewModels/TableHealthViewModel.swift b/apple/TablePro/ViewModels/TableHealthViewModel.swift
index 69d81de2ec..13a31624d1 100644
--- a/apple/TablePro/ViewModels/TableHealthViewModel.swift
+++ b/apple/TablePro/ViewModels/TableHealthViewModel.swift
@@ -24,6 +24,7 @@ final class TableHealthViewModel: ObservableObject {
private let connectionId: UUID
private let metadata: any ScopedMetadataProviding
+ private var refreshTask: Task?
var isSupported: Bool { provider != nil }
@@ -47,10 +48,25 @@ final class TableHealthViewModel: ObservableObject {
await refresh()
}
+ // The model owns the read, so a pane switch that cancels `.task` neither drops it nor leaves the
+ // next appearance waiting on nothing: a second call joins the one in flight.
func refresh() async {
- guard let provider, !isRefreshing else { return }
+ if let refreshTask {
+ await refreshTask.value
+ return
+ }
+ guard let provider else { return }
+ let task = Task { await self.performRefresh(provider) }
+ refreshTask = task
+ await task.value
+ }
+
+ private func performRefresh(_ provider: any TableHealthProvider) async {
isRefreshing = true
- defer { isRefreshing = false }
+ defer {
+ isRefreshing = false
+ refreshTask = nil
+ }
do {
guard let scope = metadata.browseScope(for: connectionId) else { throw DatabaseError.notConnected }
@@ -61,13 +77,10 @@ final class TableHealthViewModel: ObservableObject {
try await driver.executeRead(query: query)
}
}
- try Task.checkCancellation()
report = Self.merging(fresh, over: report)
loadError = nil
lastRefreshDate = Date()
if selectedFinding == nil { selectedFindingId = nil }
- } catch is CancellationError {
- return
} catch {
Self.logger.warning("Table health refresh failed: \(error.publicLogShape, privacy: .public)")
loadError = error.localizedDescription
diff --git a/apple/TablePro/Views/TableHealth/TableHealthView.swift b/apple/TablePro/Views/TableHealth/TableHealthView.swift
index 576a42c5f5..9525e85a56 100644
--- a/apple/TablePro/Views/TableHealth/TableHealthView.swift
+++ b/apple/TablePro/Views/TableHealth/TableHealthView.swift
@@ -39,8 +39,8 @@ struct TableHealthView: View {
autosaveName: "com.TablePro.tableHealth.findingsDetail",
isVertical: false,
primaryMinimum: 160,
- secondaryMinimum: 120,
- primaryThicknessFraction: 0.65
+ secondaryMinimum: 150,
+ primaryHoldingPriority: .splitPaneFlexible
) {
TableHealthFindingsList(viewModel: viewModel, actions: actions)
} secondary: {
diff --git a/apple/TableProTests/Core/TableHealth/MySQLTableHealthProviderTests.swift b/apple/TableProTests/Core/TableHealth/MySQLTableHealthProviderTests.swift
index 643db75341..46a36a2a16 100644
--- a/apple/TableProTests/Core/TableHealth/MySQLTableHealthProviderTests.swift
+++ b/apple/TableProTests/Core/TableHealth/MySQLTableHealthProviderTests.swift
@@ -40,10 +40,18 @@ struct MySQLTableHealthProviderTests {
return map
}
+ private struct KeyRow {
+ let table: String
+ let constraint: String
+ let column: String
+ var referencedTable: String?
+ var referencedColumn: String?
+ }
+
private func unused(
candidates: [(table: String, index: String)],
indexes: [IndexRow],
- foreignKeys: [(table: String, constraint: String, column: String)] = []
+ foreignKeys: [KeyRow] = []
) -> [String] {
let results = [
TableHealthFixture.result(
@@ -52,8 +60,10 @@ struct MySQLTableHealthProviderTests {
),
Self.indexResult(indexes),
TableHealthFixture.result(
- columns: ["table_name", "constraint_name", "column_name"],
- rows: foreignKeys.map { key -> [String?] in [key.table, key.constraint, key.column] }
+ columns: ["table_name", "constraint_name", "column_name", "referenced_table_name", "referenced_column_name"],
+ rows: foreignKeys.map { key -> [String?] in
+ [key.table, key.constraint, key.column, key.referencedTable, key.referencedColumn]
+ }
)
]
return provider.findings(for: .unusedIndex, from: results, context: context).compactMap(\.objectName)
@@ -242,14 +252,30 @@ struct MySQLTableHealthProviderTests {
IndexRow(table: "line", index: "line_short_idx", nonUnique: "1", column: "y")
],
foreignKeys: [
- ("line", "line_order_fk", "order_id"),
- ("line", "line_pair_fk", "x"),
- ("line", "line_pair_fk", "y")
+ KeyRow(table: "line", constraint: "line_order_fk", column: "order_id"),
+ KeyRow(table: "line", constraint: "line_pair_fk", column: "x"),
+ KeyRow(table: "line", constraint: "line_pair_fk", column: "y")
]
)
#expect(found == ["line_late_idx", "line_short_idx"])
}
+ @Test("A non-unique index a foreign key references in this database is not unused")
+ func unusedSkipsReferencedIndex() {
+ let found = unused(
+ candidates: [("orders", "orders_code_idx"), ("orders", "orders_note_idx")],
+ indexes: [
+ IndexRow(index: "orders_code_idx", nonUnique: "1", column: "code"),
+ IndexRow(index: "orders_note_idx", nonUnique: "1", column: "note")
+ ],
+ foreignKeys: [
+ KeyRow(table: "line", constraint: "line_code_fk", column: "order_code", referencedTable: "orders", referencedColumn: "code"),
+ KeyRow(table: "audit", constraint: "audit_note_fk", column: "note", referencedTable: nil, referencedColumn: "note")
+ ]
+ )
+ #expect(found == ["orders_note_idx"])
+ }
+
@Test("An unused index and a table without a primary key preview their fixes")
func unusedAndMissingKeyFixes() throws {
let unusedFinding = try #require(provider.findings(
@@ -257,7 +283,10 @@ struct MySQLTableHealthProviderTests {
from: [
TableHealthFixture.result(columns: ["table_name", "index_name"], rows: [["orders", "orders_note_idx"]]),
Self.indexResult([IndexRow(index: "orders_note_idx", nonUnique: "1", column: "note")]),
- TableHealthFixture.result(columns: ["table_name", "constraint_name", "column_name"], rows: [])
+ TableHealthFixture.result(
+ columns: ["table_name", "constraint_name", "column_name", "referenced_table_name", "referenced_column_name"],
+ rows: []
+ )
],
context: context
).first)
diff --git a/apple/TableProTests/ViewModels/TableHealthViewModelTests.swift b/apple/TableProTests/ViewModels/TableHealthViewModelTests.swift
index 8fa65177c8..c0a767b439 100644
--- a/apple/TableProTests/ViewModels/TableHealthViewModelTests.swift
+++ b/apple/TableProTests/ViewModels/TableHealthViewModelTests.swift
@@ -1,6 +1,7 @@
import Foundation
@testable import TablePro
import TableProPluginKit
+import TableProTestSupport
import Testing
private struct Offline: LocalizedError {
@@ -65,6 +66,8 @@ private final class HealthMetadata: ScopedMetadataProviding {
var database = "shop"
var isConnected = true
var leaseFails = false
+ // Holds the next lease until the test releases it, the way a slow catalog read holds one.
+ var hold: (reached: AsyncGate, release: AsyncGate)?
private(set) var leases: [(scope: DatabaseScope, workload: MetadataConnectionPool.Workload)] = []
func withMetadataDriver(
@@ -73,6 +76,11 @@ private final class HealthMetadata: ScopedMetadataProviding {
_ body: @Sendable @escaping (DatabaseDriver) async throws -> T
) async throws -> T {
leases.append((scope, workload))
+ if let hold {
+ self.hold = nil
+ await hold.reached.open()
+ await hold.release.wait()
+ }
if leaseFails { throw Offline() }
return try await body(PluginDriverAdapter(connection: connection, pluginDriver: HealthPluginDriver(server: server)))
}
@@ -136,6 +144,25 @@ struct TableHealthViewModelTests {
#expect(viewModel.report?.findingCount == 1)
}
+ @Test("A pane switch during the first load neither drops it nor strands the next appearance")
+ func paneSwitchDuringFirstLoad() async {
+ let metadata = HealthMetadata()
+ let hold = (reached: AsyncGate(), release: AsyncGate())
+ metadata.hold = hold
+ let viewModel = makeViewModel(metadata)
+
+ let firstAppearance = Task { await viewModel.loadIfNeeded() }
+ await hold.reached.wait()
+ firstAppearance.cancel()
+ let secondAppearance = Task { await viewModel.loadIfNeeded() }
+ await hold.release.open()
+ await secondAppearance.value
+
+ #expect(viewModel.report?.findingCount == 1)
+ #expect(metadata.leases.count == 1)
+ #expect(!viewModel.isRefreshing)
+ }
+
@Test("A connection with no database selected asks for one and reads nothing")
func serverScopedAsksForDatabase() async {
let metadata = HealthMetadata()
diff --git a/apple/scripts/probes/check-table-health-catalogs.sh b/apple/scripts/probes/check-table-health-catalogs.sh
index 3c51bfa627..6852371e05 100755
--- a/apple/scripts/probes/check-table-health-catalogs.sh
+++ b/apple/scripts/probes/check-table-health-catalogs.sh
@@ -216,15 +216,21 @@ check_mysql() {
echo "$(my -e 'SELECT VERSION()') at $HOST:$PORT"
my -e "DROP DATABASE IF EXISTS $DATABASE; CREATE DATABASE $DATABASE; DROP DATABASE IF EXISTS $OTHER_DATABASE; CREATE DATABASE $OTHER_DATABASE"
- trap 'mysql --protocol=TCP -h "$HOST" -P "$PORT" -u "$USER_NAME" -e "DROP DATABASE IF EXISTS '"$DATABASE"'; DROP DATABASE IF EXISTS '"$OTHER_DATABASE"'" > /dev/null 2>&1' EXIT
+ trap 'mysql --protocol=TCP -h "$HOST" -P "$PORT" -u "$USER_NAME" -e "DROP DATABASE IF EXISTS '"$DATABASE"'; DROP DATABASE IF EXISTS '"$OTHER_DATABASE"'; UPDATE performance_schema.setup_instruments SET ENABLED = '"'YES'"' WHERE NAME = '"'wait/io/table/sql/handler'"'; DELETE FROM performance_schema.setup_objects WHERE OBJECT_SCHEMA = '"'$DATABASE'"'" > /dev/null 2>&1' EXIT
my "$OTHER_DATABASE" -e "CREATE TABLE other_no_pk (v INT, KEY other_v_idx (v)); INSERT INTO other_no_pk VALUES (1)"
+ # A table setup_objects leaves out keeps zero-count index rows even after a read through the index.
+ my -e "INSERT INTO performance_schema.setup_objects (OBJECT_TYPE, OBJECT_SCHEMA, OBJECT_NAME, ENABLED, TIMED) VALUES ('TABLE', '$DATABASE', 'uncounted', 'NO', 'NO')" 2> /dev/null
my "$DATABASE" > /dev/null <<'SQL' || exit 1
CREATE TABLE parent (id INT PRIMARY KEY);
CREATE TABLE child (id INT PRIMARY KEY, parent_id INT, note INT, CONSTRAINT child_fk FOREIGN KEY (parent_id) REFERENCES parent (id), KEY child_note_idx (note));
CREATE TABLE no_pk (a INT, b INT, KEY no_pk_a_idx (a), KEY no_pk_ab_idx (a, b), UNIQUE KEY no_pk_b_uniq (b));
CREATE TABLE `odd``name` (v INT);
CREATE TABLE healthy (id INT PRIMARY KEY, v INT, KEY healthy_v_idx (v));
+CREATE TABLE uncounted (id INT PRIMARY KEY, v INT, KEY uncounted_v_idx (v));
+INSERT INTO uncounted VALUES (1, 1), (2, 2);
+FLUSH TABLES;
+SELECT COUNT(*) FROM uncounted FORCE INDEX (uncounted_v_idx) WHERE v = 2;
INSERT INTO parent VALUES (1);
INSERT INTO child VALUES (1, 1, 1);
INSERT INTO healthy VALUES (1, 1), (2, 2), (3, 3);
@@ -259,6 +265,7 @@ SQL
expect "unused candidates list the foreign key's own index, which InnoDB reads uncounted" "$unused" "child child_fk" yes
expect "unused candidates skip an index a query read" "$unused" "healthy_v_idx" no
expect "unused candidates skip another database" "$unused" "other_v_idx" no
+ expect "unused candidates skip a table setup_objects leaves uncounted" "$unused" "uncounted_v_idx" no
fi
local drop_fk_index
drop_fk_index="$(my "$DATABASE" -e 'ALTER TABLE child DROP INDEX child_fk' 2>&1)"
@@ -278,7 +285,7 @@ SQL
local keys
keys="$(my "$DATABASE" -e "$(query_of "$source" foreignKeyQuery)")"
- expect "foreign key rows name the table, key and column" "$keys" "child child_fk parent_id" yes
+ expect "foreign key rows name both ends" "$keys" "child child_fk parent_id parent id" yes
local nopk
nopk="$(my "$DATABASE" -e "$(query_of "$source" missingPrimaryKeyQuery)")"
From bc4461684d0816ef869b003c15798ba85cc7665e Mon Sep 17 00:00:00 2001
From: Ngo Quoc Dat
Date: Sun, 1 Nov 2026 06:18:15 +0700
Subject: [PATCH 09/10] fix(table-health): protect indexes behind keys from
other databases, skip full-text redundancy, theme the preview
---
.../MySQLTableHealthProvider.swift | 44 ++++++++-----
.../Main/Child/MainEditorContentView.swift | 2 +-
.../Views/TableHealth/TableHealthView.swift | 15 +++--
.../MySQLTableHealthProviderTests.swift | 64 ++++++++++++++-----
.../probes/check-table-health-catalogs.sh | 11 ++--
5 files changed, 92 insertions(+), 44 deletions(-)
diff --git a/apple/TablePro/Core/TableHealth/MySQLTableHealthProvider.swift b/apple/TablePro/Core/TableHealth/MySQLTableHealthProvider.swift
index 0197a0e7ed..3e9094ebf4 100644
--- a/apple/TablePro/Core/TableHealth/MySQLTableHealthProvider.swift
+++ b/apple/TablePro/Core/TableHealth/MySQLTableHealthProvider.swift
@@ -48,18 +48,21 @@ struct MySQLTableHealthProvider: TableHealthProvider {
ORDER BY TABLE_NAME, INDEX_NAME, SEQ_IN_INDEX
"""
- // Both ends: InnoDB checks a key through an index on each side, and MariaDB lets the referenced
- // one be non-unique. A key from another database is not read: on MariaDB that scans every database.
+ // Both ends, from any database: InnoDB checks a key through an index on each side, and MariaDB
+ // lets the referenced one be non-unique.
static let foreignKeyQuery = """
- SELECT k.TABLE_NAME AS table_name,
+ SELECT k.TABLE_SCHEMA AS table_schema,
+ k.TABLE_NAME AS table_name,
k.CONSTRAINT_NAME AS constraint_name,
k.COLUMN_NAME AS column_name,
- CASE WHEN k.REFERENCED_TABLE_SCHEMA = DATABASE() THEN k.REFERENCED_TABLE_NAME END AS referenced_table_name,
- k.REFERENCED_COLUMN_NAME AS referenced_column_name
+ k.REFERENCED_TABLE_NAME AS referenced_table_name,
+ k.REFERENCED_COLUMN_NAME AS referenced_column_name,
+ k.TABLE_SCHEMA = DATABASE() AS is_local,
+ k.REFERENCED_TABLE_SCHEMA = DATABASE() AS references_here
FROM information_schema.KEY_COLUMN_USAGE k
- WHERE k.TABLE_SCHEMA = DATABASE()
- AND k.REFERENCED_TABLE_NAME IS NOT NULL
- ORDER BY k.TABLE_NAME, k.CONSTRAINT_NAME, k.ORDINAL_POSITION
+ WHERE k.REFERENCED_TABLE_NAME IS NOT NULL
+ AND (k.TABLE_SCHEMA = DATABASE() OR k.REFERENCED_TABLE_SCHEMA = DATABASE())
+ ORDER BY k.TABLE_SCHEMA, k.TABLE_NAME, k.CONSTRAINT_NAME, k.ORDINAL_POSITION
"""
// MariaDB lists a system-versioned table under its own type.
@@ -221,9 +224,10 @@ struct MySQLIndexShape {
var isPrimary: Bool { name == "PRIMARY" }
// Only a B-tree serves a lookup on its leading columns, so another type is redundant only
- // to an identical index. Of two equal indexes the unique one stays, else the first by name.
+ // to an identical index. A full-text index never is: STATISTICS does not name its parser.
+ // Of two equal indexes the unique one stays, else the first by name.
func isRedundant(to other: Index) -> Bool {
- guard other.name != name, other.isVisible, !isPrimary, !hasExpression, !other.hasExpression,
+ guard other.name != name, other.isVisible, type != "FULLTEXT", !isPrimary, !hasExpression, !other.hasExpression,
other.type == type, columns.count <= other.columns.count,
type == "BTREE" || columns.count == other.columns.count,
Array(other.columns.prefix(columns.count)) == columns
@@ -276,32 +280,40 @@ struct MySQLIndexShape {
return tables
}
- // Per table, the columns each foreign key reads through it, on the referencing or the referenced end, in key order.
+ // Per table of this database, the columns each foreign key reads through it, on the referencing
+ // or the referenced end, in key order.
static func foreignKeys(from rows: [TableHealthRow]) -> [String: [[String]]] {
struct Key {
+ let schema: String
let table: String
let constraint: String
+ let isLocal: Bool
+ let referencesHere: Bool
let referencedTable: String
var columns: [String]
var referencedColumns: [String]
}
var keys: [Key] = []
for row in rows {
- let table = row["table_name"], constraint = row["constraint_name"]
- if let last = keys.last, last.table == table, last.constraint == constraint {
+ let schema = row["table_schema"], table = row["table_name"], constraint = row["constraint_name"]
+ if let last = keys.last, last.schema == schema, last.table == table, last.constraint == constraint {
keys[keys.count - 1].columns.append(row["column_name"])
keys[keys.count - 1].referencedColumns.append(row["referenced_column_name"])
} else {
keys.append(Key(
- table: table, constraint: constraint, referencedTable: row["referenced_table_name"],
+ schema: schema, table: table, constraint: constraint,
+ isLocal: row["is_local"] == "1", referencesHere: row["references_here"] == "1",
+ referencedTable: row["referenced_table_name"],
columns: [row["column_name"]], referencedColumns: [row["referenced_column_name"]]
))
}
}
var byTable: [String: [[String]]] = [:]
for key in keys {
- byTable[key.table, default: []].append(key.columns)
- if !key.referencedTable.isEmpty {
+ if key.isLocal {
+ byTable[key.table, default: []].append(key.columns)
+ }
+ if key.referencesHere {
byTable[key.referencedTable, default: []].append(key.referencedColumns)
}
}
diff --git a/apple/TablePro/Views/Main/Child/MainEditorContentView.swift b/apple/TablePro/Views/Main/Child/MainEditorContentView.swift
index 6e217ba703..458ab4de9a 100644
--- a/apple/TablePro/Views/Main/Child/MainEditorContentView.swift
+++ b/apple/TablePro/Views/Main/Child/MainEditorContentView.swift
@@ -329,7 +329,7 @@ struct MainEditorContentView: View {
private func tableHealthContent(tab: QueryTab) -> some View {
Group {
if let vm = tableHealthViewModels[tab.id] {
- TableHealthView(viewModel: vm, actions: tableHealthActions)
+ TableHealthView(viewModel: vm, databaseType: connection.type, actions: tableHealthActions)
.onReceive(AppEvents.shared.tabRefreshRequested) { requestedTabId in
guard requestedTabId == tab.id else { return }
Task { await vm.refresh() }
diff --git a/apple/TablePro/Views/TableHealth/TableHealthView.swift b/apple/TablePro/Views/TableHealth/TableHealthView.swift
index 9525e85a56..da5705215e 100644
--- a/apple/TablePro/Views/TableHealth/TableHealthView.swift
+++ b/apple/TablePro/Views/TableHealth/TableHealthView.swift
@@ -12,6 +12,7 @@ struct TableHealthActions {
struct TableHealthView: View {
@ObservedObject var viewModel: TableHealthViewModel
+ let databaseType: DatabaseType
let actions: TableHealthActions
// A VStack, not safeAreaInset: the inset does not reach a List hosted inside the split view.
@@ -44,7 +45,7 @@ struct TableHealthView: View {
) {
TableHealthFindingsList(viewModel: viewModel, actions: actions)
} secondary: {
- TableHealthDetailPane(viewModel: viewModel, actions: actions)
+ TableHealthDetailPane(viewModel: viewModel, databaseType: databaseType, actions: actions)
}
} else if let error = viewModel.loadError {
UnavailableStateView {
@@ -186,6 +187,7 @@ private struct TableHealthFindingsList: View {
private struct TableHealthDetailPane: View {
@ObservedObject var viewModel: TableHealthViewModel
+ let databaseType: DatabaseType
let actions: TableHealthActions
var body: some View {
@@ -201,12 +203,11 @@ private struct TableHealthDetailPane: View {
Button(String(localized: "Copy")) { actions.copyFix(finding) }
Button(String(localized: "Open in Editor")) { actions.openFix(finding, database) }
}
- ScrollView {
- Text(verbatim: finding.fixSQL)
- .font(.system(.callout, design: .monospaced))
- .textSelection(.enabled)
- .frame(maxWidth: .infinity, alignment: .leading)
- }
+ SQLStatementPreview(
+ prepared: SQLReviewSheet.build(statements: [finding.fixSQL], databaseType: databaseType),
+ databaseType: databaseType
+ )
+ .id(finding.id)
Text("This SQL is a preview. TablePro never runs it; review it in the editor and run it yourself.")
.font(.caption)
.foregroundStyle(.secondary)
diff --git a/apple/TableProTests/Core/TableHealth/MySQLTableHealthProviderTests.swift b/apple/TableProTests/Core/TableHealth/MySQLTableHealthProviderTests.swift
index 46a36a2a16..d03013ab5f 100644
--- a/apple/TableProTests/Core/TableHealth/MySQLTableHealthProviderTests.swift
+++ b/apple/TableProTests/Core/TableHealth/MySQLTableHealthProviderTests.swift
@@ -41,13 +41,22 @@ struct MySQLTableHealthProviderTests {
}
private struct KeyRow {
+ var schema = "shop"
let table: String
let constraint: String
let column: String
- var referencedTable: String?
- var referencedColumn: String?
+ var referencedTable = "parent"
+ var referencedColumn = "id"
+ var referencesHere = false
+
+ var isLocal: Bool { schema == "shop" }
}
+ private static let keyColumns = [
+ "table_schema", "table_name", "constraint_name", "column_name",
+ "referenced_table_name", "referenced_column_name", "is_local", "references_here"
+ ]
+
private func unused(
candidates: [(table: String, index: String)],
indexes: [IndexRow],
@@ -60,9 +69,12 @@ struct MySQLTableHealthProviderTests {
),
Self.indexResult(indexes),
TableHealthFixture.result(
- columns: ["table_name", "constraint_name", "column_name", "referenced_table_name", "referenced_column_name"],
+ columns: Self.keyColumns,
rows: foreignKeys.map { key -> [String?] in
- [key.table, key.constraint, key.column, key.referencedTable, key.referencedColumn]
+ [
+ key.schema, key.table, key.constraint, key.column, key.referencedTable, key.referencedColumn,
+ key.isLocal ? "1" : "0", key.referencesHere ? "1" : "0"
+ ]
}
)
]
@@ -151,8 +163,8 @@ struct MySQLTableHealthProviderTests {
#expect(found.isEmpty)
}
- @Test("A full-text index is redundant only to an identical one, never to a longer one")
- func fullTextNeedsExactColumns() {
+ @Test("A full-text index is never redundant: two on the same columns can tokenize with different parsers")
+ func fullTextIsNeverRedundant() {
let prefix = redundant([
IndexRow(index: "ft_a", nonUnique: "1", type: "FULLTEXT", column: "a", collation: nil),
IndexRow(index: "ft_ab", nonUnique: "1", type: "FULLTEXT", column: "a", collation: nil),
@@ -163,7 +175,7 @@ struct MySQLTableHealthProviderTests {
IndexRow(index: "ft_a2", nonUnique: "1", type: "FULLTEXT", column: "a", collation: nil)
])
#expect(prefix.isEmpty)
- #expect(Array(identical.keys) == ["ft_a2"])
+ #expect(identical.isEmpty)
}
@Test("A key part sorted the other way keeps an index apart")
@@ -260,20 +272,43 @@ struct MySQLTableHealthProviderTests {
#expect(found == ["line_late_idx", "line_short_idx"])
}
- @Test("A non-unique index a foreign key references in this database is not unused")
+ @Test("A non-unique index a foreign key references, from this database or another, is not unused")
func unusedSkipsReferencedIndex() {
let found = unused(
- candidates: [("orders", "orders_code_idx"), ("orders", "orders_note_idx")],
+ candidates: [("orders", "orders_code_idx"), ("orders", "orders_note_idx"), ("orders", "orders_misc_idx")],
indexes: [
IndexRow(index: "orders_code_idx", nonUnique: "1", column: "code"),
+ IndexRow(index: "orders_misc_idx", nonUnique: "1", column: "misc"),
IndexRow(index: "orders_note_idx", nonUnique: "1", column: "note")
],
foreignKeys: [
- KeyRow(table: "line", constraint: "line_code_fk", column: "order_code", referencedTable: "orders", referencedColumn: "code"),
- KeyRow(table: "audit", constraint: "audit_note_fk", column: "note", referencedTable: nil, referencedColumn: "note")
+ KeyRow(
+ schema: "archive", table: "audit", constraint: "audit_note_fk", column: "note",
+ referencedTable: "orders", referencedColumn: "note", referencesHere: true
+ ),
+ KeyRow(
+ table: "line", constraint: "line_code_fk", column: "order_code",
+ referencedTable: "orders", referencedColumn: "code", referencesHere: true
+ ),
+ KeyRow(table: "orders", constraint: "orders_misc_fk", column: "misc", referencedTable: "misc", referencedColumn: "id")
]
)
- #expect(found == ["orders_note_idx"])
+ #expect(found == [])
+ }
+
+ @Test("A key that leaves this database protects only its referencing end here")
+ func outgoingKeyProtectsOnlyItsOwnEnd() {
+ let found = unused(
+ candidates: [("orders", "orders_ref_idx"), ("refs", "refs_id_idx")],
+ indexes: [
+ IndexRow(index: "orders_ref_idx", nonUnique: "1", column: "ref_id"),
+ IndexRow(table: "refs", index: "refs_id_idx", nonUnique: "1", column: "id")
+ ],
+ foreignKeys: [
+ KeyRow(table: "orders", constraint: "orders_ref_fk", column: "ref_id", referencedTable: "refs", referencedColumn: "id")
+ ]
+ )
+ #expect(found == ["refs_id_idx"])
}
@Test("An unused index and a table without a primary key preview their fixes")
@@ -283,10 +318,7 @@ struct MySQLTableHealthProviderTests {
from: [
TableHealthFixture.result(columns: ["table_name", "index_name"], rows: [["orders", "orders_note_idx"]]),
Self.indexResult([IndexRow(index: "orders_note_idx", nonUnique: "1", column: "note")]),
- TableHealthFixture.result(
- columns: ["table_name", "constraint_name", "column_name", "referenced_table_name", "referenced_column_name"],
- rows: []
- )
+ TableHealthFixture.result(columns: Self.keyColumns, rows: [])
],
context: context
).first)
diff --git a/apple/scripts/probes/check-table-health-catalogs.sh b/apple/scripts/probes/check-table-health-catalogs.sh
index 6852371e05..1663ca4977 100755
--- a/apple/scripts/probes/check-table-health-catalogs.sh
+++ b/apple/scripts/probes/check-table-health-catalogs.sh
@@ -215,10 +215,10 @@ check_mysql() {
my -e "SELECT 1" > /dev/null || { echo "no MySQL at $HOST:$PORT" >&2; exit 3; }
echo "$(my -e 'SELECT VERSION()') at $HOST:$PORT"
- my -e "DROP DATABASE IF EXISTS $DATABASE; CREATE DATABASE $DATABASE; DROP DATABASE IF EXISTS $OTHER_DATABASE; CREATE DATABASE $OTHER_DATABASE"
- trap 'mysql --protocol=TCP -h "$HOST" -P "$PORT" -u "$USER_NAME" -e "DROP DATABASE IF EXISTS '"$DATABASE"'; DROP DATABASE IF EXISTS '"$OTHER_DATABASE"'; UPDATE performance_schema.setup_instruments SET ENABLED = '"'YES'"' WHERE NAME = '"'wait/io/table/sql/handler'"'; DELETE FROM performance_schema.setup_objects WHERE OBJECT_SCHEMA = '"'$DATABASE'"'" > /dev/null 2>&1' EXIT
+ # The other database holds a key into this one, so it is dropped first.
+ my -e "DROP DATABASE IF EXISTS $OTHER_DATABASE; DROP DATABASE IF EXISTS $DATABASE; CREATE DATABASE $DATABASE; CREATE DATABASE $OTHER_DATABASE"
+ trap 'mysql --protocol=TCP -h "$HOST" -P "$PORT" -u "$USER_NAME" -e "DROP DATABASE IF EXISTS '"$OTHER_DATABASE"'; DROP DATABASE IF EXISTS '"$DATABASE"'; UPDATE performance_schema.setup_instruments SET ENABLED = '"'YES'"' WHERE NAME = '"'wait/io/table/sql/handler'"'; DELETE FROM performance_schema.setup_objects WHERE OBJECT_SCHEMA = '"'$DATABASE'"'" > /dev/null 2>&1' EXIT
- my "$OTHER_DATABASE" -e "CREATE TABLE other_no_pk (v INT, KEY other_v_idx (v)); INSERT INTO other_no_pk VALUES (1)"
# A table setup_objects leaves out keeps zero-count index rows even after a read through the index.
my -e "INSERT INTO performance_schema.setup_objects (OBJECT_TYPE, OBJECT_SCHEMA, OBJECT_NAME, ENABLED, TIMED) VALUES ('TABLE', '$DATABASE', 'uncounted', 'NO', 'NO')" 2> /dev/null
my "$DATABASE" > /dev/null <<'SQL' || exit 1
@@ -237,6 +237,8 @@ INSERT INTO healthy VALUES (1, 1), (2, 2), (3, 3);
INSERT INTO no_pk VALUES (1, 1), (2, 2);
SELECT COUNT(*) FROM healthy FORCE INDEX (healthy_v_idx) WHERE v = 2;
SQL
+ my "$OTHER_DATABASE" -e "CREATE TABLE other_no_pk (v INT, KEY other_v_idx (v)); INSERT INTO other_no_pk VALUES (1)" || exit 1
+ my "$OTHER_DATABASE" -e "CREATE TABLE other_child (id INT PRIMARY KEY, parent_id INT, CONSTRAINT other_child_fk FOREIGN KEY (parent_id) REFERENCES \`$DATABASE\`.parent (id))" || exit 1
local ps
ps="$(my "$DATABASE" -e "$(query_of "$source" performanceSchemaQuery)")"
@@ -285,7 +287,8 @@ SQL
local keys
keys="$(my "$DATABASE" -e "$(query_of "$source" foreignKeyQuery)")"
- expect "foreign key rows name both ends" "$keys" "child child_fk parent_id parent id" yes
+ expect "foreign key rows name both ends of a local key" "$keys" "$DATABASE child child_fk parent_id parent id 1 1" yes
+ expect "foreign key rows include a key from another database into this one" "$keys" "$OTHER_DATABASE other_child other_child_fk parent_id parent id 0 1" yes
local nopk
nopk="$(my "$DATABASE" -e "$(query_of "$source" missingPrimaryKeyQuery)")"
From 3c2384e37cbd23c8cd2b5c609d300b4ed2dcaa1f Mon Sep 17 00:00:00 2001
From: Ngo Quoc Dat
Date: Sun, 1 Nov 2026 06:28:59 +0700
Subject: [PATCH 10/10] fix(table-health): group MySQL index rows by exact name
and skip full-text indexes as unused
---
.../MySQLTableHealthProvider.swift | 70 +++++++++++--------
.../MySQLTableHealthProviderTests.swift | 66 +++++++++++++----
.../probes/check-table-health-catalogs.sh | 4 +-
3 files changed, 97 insertions(+), 43 deletions(-)
diff --git a/apple/TablePro/Core/TableHealth/MySQLTableHealthProvider.swift b/apple/TablePro/Core/TableHealth/MySQLTableHealthProvider.swift
index 3e9094ebf4..fdeb5f3f91 100644
--- a/apple/TablePro/Core/TableHealth/MySQLTableHealthProvider.swift
+++ b/apple/TablePro/Core/TableHealth/MySQLTableHealthProvider.swift
@@ -57,6 +57,7 @@ struct MySQLTableHealthProvider: TableHealthProvider {
k.COLUMN_NAME AS column_name,
k.REFERENCED_TABLE_NAME AS referenced_table_name,
k.REFERENCED_COLUMN_NAME AS referenced_column_name,
+ k.ORDINAL_POSITION AS ordinal_position,
k.TABLE_SCHEMA = DATABASE() AS is_local,
k.REFERENCED_TABLE_SCHEMA = DATABASE() AS references_here
FROM information_schema.KEY_COLUMN_USAGE k
@@ -149,8 +150,9 @@ struct MySQLTableHealthProvider: TableHealthProvider {
}
}
- // A unique index enforces something no read counts. InnoDB checks a foreign key through an
- // index that leads with its columns on either end without counting a read, and refuses to drop the last one.
+ // A unique index enforces something no read counts, and a MATCH search reads a full-text index
+ // uncounted. InnoDB checks a foreign key through an index that leads with its columns on either
+ // end without counting a read, and refuses to drop the last one.
private func unusedIndexFindings(
candidates: [TableHealthRow],
tables: [MySQLIndexShape],
@@ -162,6 +164,7 @@ struct MySQLTableHealthProvider: TableHealthProvider {
let table = row["table_name"], indexName = row["index_name"]
guard let index = shapes[table]?.indexes.first(where: { $0.name == indexName }),
!index.isUnique,
+ index.type != "FULLTEXT",
!(foreignKeys[table] ?? []).contains(where: index.serves)
else { return nil }
return TableHealthFinding(
@@ -249,16 +252,24 @@ struct MySQLIndexShape {
let name: String
var indexes: [Index]
+ // Grouped by exact name and ordered by position: the catalog sorts names without case, so on a
+ // case-sensitive server the rows of `Orders` and `orders` can interleave.
static func tables(from rows: [TableHealthRow]) -> [MySQLIndexShape] {
var tables: [MySQLIndexShape] = []
+ var tablePositions: [String: Int] = [:]
+ var keyParts: [String: [(position: Int, column: Column?)]] = [:]
for row in rows {
let table = row["table_name"], indexName = row["index_name"]
- if tables.last?.name != table {
+ let tablePosition: Int
+ if let known = tablePositions[table] {
+ tablePosition = known
+ } else {
+ tablePosition = tables.count
+ tablePositions[table] = tablePosition
tables.append(MySQLIndexShape(name: table, indexes: []))
}
- let tableIndex = tables.count - 1
- if tables[tableIndex].indexes.last?.name != indexName {
- tables[tableIndex].indexes.append(Index(
+ if !tables[tablePosition].indexes.contains(where: { $0.name == indexName }) {
+ tables[tablePosition].indexes.append(Index(
name: indexName,
isUnique: row["non_unique"] == "0",
type: row["index_type"],
@@ -267,54 +278,57 @@ struct MySQLIndexShape {
hasExpression: false
))
}
- let last = tables[tableIndex].indexes.count - 1
let column = row["column_name"]
- if column.isEmpty {
- tables[tableIndex].indexes[last].hasExpression = true
- } else {
- tables[tableIndex].indexes[last].columns.append(
- Column(name: column, prefixLength: row["sub_part"], sortOrder: row["collation"])
- )
+ keyParts[table + "\u{1F}" + indexName, default: []].append((
+ Int(row["seq_in_index"]) ?? 0,
+ column.isEmpty ? nil : Column(name: column, prefixLength: row["sub_part"], sortOrder: row["collation"])
+ ))
+ }
+ for tablePosition in tables.indices {
+ let table = tables[tablePosition].name
+ for indexPosition in tables[tablePosition].indexes.indices {
+ let parts = (keyParts[table + "\u{1F}" + tables[tablePosition].indexes[indexPosition].name] ?? [])
+ .sorted { $0.position < $1.position }
+ tables[tablePosition].indexes[indexPosition].hasExpression = parts.contains { $0.column == nil }
+ tables[tablePosition].indexes[indexPosition].columns = parts.compactMap(\.column)
}
}
return tables
}
// Per table of this database, the columns each foreign key reads through it, on the referencing
- // or the referenced end, in key order.
+ // or the referenced end, in key order. Grouped by exact name, like the index rows.
static func foreignKeys(from rows: [TableHealthRow]) -> [String: [[String]]] {
struct Key {
- let schema: String
- let table: String
- let constraint: String
let isLocal: Bool
let referencesHere: Bool
+ let table: String
let referencedTable: String
- var columns: [String]
- var referencedColumns: [String]
+ var parts: [(position: Int, column: String, referencedColumn: String)]
}
var keys: [Key] = []
+ var keyPositions: [String: Int] = [:]
for row in rows {
- let schema = row["table_schema"], table = row["table_name"], constraint = row["constraint_name"]
- if let last = keys.last, last.schema == schema, last.table == table, last.constraint == constraint {
- keys[keys.count - 1].columns.append(row["column_name"])
- keys[keys.count - 1].referencedColumns.append(row["referenced_column_name"])
+ let identity = [row["table_schema"], row["table_name"], row["constraint_name"]].joined(separator: "\u{1F}")
+ let part = (Int(row["ordinal_position"]) ?? 0, row["column_name"], row["referenced_column_name"])
+ if let known = keyPositions[identity] {
+ keys[known].parts.append(part)
} else {
+ keyPositions[identity] = keys.count
keys.append(Key(
- schema: schema, table: table, constraint: constraint,
isLocal: row["is_local"] == "1", referencesHere: row["references_here"] == "1",
- referencedTable: row["referenced_table_name"],
- columns: [row["column_name"]], referencedColumns: [row["referenced_column_name"]]
+ table: row["table_name"], referencedTable: row["referenced_table_name"], parts: [part]
))
}
}
var byTable: [String: [[String]]] = [:]
for key in keys {
+ let parts = key.parts.sorted { $0.position < $1.position }
if key.isLocal {
- byTable[key.table, default: []].append(key.columns)
+ byTable[key.table, default: []].append(parts.map(\.column))
}
if key.referencesHere {
- byTable[key.referencedTable, default: []].append(key.referencedColumns)
+ byTable[key.referencedTable, default: []].append(parts.map(\.referencedColumn))
}
}
return byTable
diff --git a/apple/TableProTests/Core/TableHealth/MySQLTableHealthProviderTests.swift b/apple/TableProTests/Core/TableHealth/MySQLTableHealthProviderTests.swift
index d03013ab5f..4841190650 100644
--- a/apple/TableProTests/Core/TableHealth/MySQLTableHealthProviderTests.swift
+++ b/apple/TableProTests/Core/TableHealth/MySQLTableHealthProviderTests.swift
@@ -16,18 +16,28 @@ struct MySQLTableHealthProviderTests {
var subPart: String?
var collation: String? = "A"
var visible: String?
+ var position: Int?
}
// The columns a SELECT * on STATISTICS returns, as MySQL 8 names them.
private static let indexColumns = [
- "TABLE_NAME", "INDEX_NAME", "NON_UNIQUE", "INDEX_TYPE", "COLUMN_NAME", "SUB_PART", "COLLATION", "IS_VISIBLE"
+ "TABLE_NAME", "INDEX_NAME", "SEQ_IN_INDEX", "NON_UNIQUE", "INDEX_TYPE", "COLUMN_NAME", "SUB_PART", "COLLATION",
+ "IS_VISIBLE"
]
+ // A row's position defaults to its place among the earlier rows of its index.
private static func indexResult(_ rows: [IndexRow]) -> QueryResult {
- TableHealthFixture.result(
+ var seen: [String: Int] = [:]
+ return TableHealthFixture.result(
columns: indexColumns,
rows: rows.map { row -> [String?] in
- [row.table, row.index, row.nonUnique, row.type, row.column, row.subPart, row.collation, row.visible]
+ let key = row.table + "." + row.index
+ seen[key, default: 0] += 1
+ let position = row.position ?? seen[key, default: 1]
+ return [
+ row.table, row.index, String(position), row.nonUnique, row.type, row.column, row.subPart,
+ row.collation, row.visible
+ ]
}
)
}
@@ -54,7 +64,7 @@ struct MySQLTableHealthProviderTests {
private static let keyColumns = [
"table_schema", "table_name", "constraint_name", "column_name",
- "referenced_table_name", "referenced_column_name", "is_local", "references_here"
+ "referenced_table_name", "referenced_column_name", "ordinal_position", "is_local", "references_here"
]
private func unused(
@@ -62,6 +72,7 @@ struct MySQLTableHealthProviderTests {
indexes: [IndexRow],
foreignKeys: [KeyRow] = []
) -> [String] {
+ var seen: [String: Int] = [:]
let results = [
TableHealthFixture.result(
columns: ["table_name", "index_name"],
@@ -71,9 +82,11 @@ struct MySQLTableHealthProviderTests {
TableHealthFixture.result(
columns: Self.keyColumns,
rows: foreignKeys.map { key -> [String?] in
- [
+ let identity = key.schema + "." + key.table + "." + key.constraint
+ seen[identity, default: 0] += 1
+ return [
key.schema, key.table, key.constraint, key.column, key.referencedTable, key.referencedColumn,
- key.isLocal ? "1" : "0", key.referencesHere ? "1" : "0"
+ String(seen[identity, default: 1]), key.isLocal ? "1" : "0", key.referencesHere ? "1" : "0"
]
}
)
@@ -199,11 +212,14 @@ struct MySQLTableHealthProviderTests {
let ignored = provider.findings(
for: .redundantIndex,
from: [TableHealthFixture.result(
- columns: ["TABLE_NAME", "INDEX_NAME", "NON_UNIQUE", "INDEX_TYPE", "COLUMN_NAME", "SUB_PART", "COLLATION", "IGNORED"],
+ columns: [
+ "TABLE_NAME", "INDEX_NAME", "SEQ_IN_INDEX", "NON_UNIQUE", "INDEX_TYPE", "COLUMN_NAME", "SUB_PART", "COLLATION",
+ "IGNORED"
+ ],
rows: [
- ["orders", "idx_a", "1", "BTREE", "a", nil, "A", "NO"],
- ["orders", "idx_ab", "1", "BTREE", "a", nil, "A", "YES"],
- ["orders", "idx_ab", "1", "BTREE", "b", nil, "A", "YES"]
+ ["orders", "idx_a", "1", "1", "BTREE", "a", nil, "A", "NO"],
+ ["orders", "idx_ab", "1", "1", "BTREE", "a", nil, "A", "YES"],
+ ["orders", "idx_ab", "2", "1", "BTREE", "b", nil, "A", "YES"]
]
)],
context: context
@@ -212,6 +228,27 @@ struct MySQLTableHealthProviderTests {
#expect(ignored.isEmpty)
}
+ @Test("Rows group by exact table name, so tables whose names differ only in case cannot split an index")
+ func caseDistinctTablesStayWhole() {
+ let found = redundant([
+ IndexRow(table: "orders", index: "idx_xy", nonUnique: "1", column: "x"),
+ IndexRow(table: "Orders", index: "idx_q", nonUnique: "1", column: "q"),
+ IndexRow(table: "orders", index: "idx_xy", nonUnique: "1", column: "y"),
+ IndexRow(table: "orders", index: "idx_y", nonUnique: "1", column: "y")
+ ])
+ #expect(found.isEmpty)
+ }
+
+ @Test("Key parts compare in index order, whatever order the rows arrive in")
+ func keyPartsFollowPosition() {
+ let found = redundant([
+ IndexRow(index: "idx_ab", nonUnique: "1", column: "b", position: 2),
+ IndexRow(index: "idx_ab", nonUnique: "1", column: "a", position: 1),
+ IndexRow(index: "idx_a", nonUnique: "1", column: "a")
+ ])
+ #expect(Array(found.keys) == ["idx_a"])
+ }
+
@Test("Indexes compare only within their own table")
func tablesStayApart() {
let found = redundant([
@@ -237,11 +274,14 @@ struct MySQLTableHealthProviderTests {
#expect(finding.fixSQL == "ALTER TABLE `shop`.`odd``name` DROP INDEX `idx_a`;")
}
- @Test("An unread plain index is unused; a unique index and one gone from the catalog are not")
- func unusedSkipsUniqueAndDropped() {
+ @Test("An unread plain index is unused; a unique, a full-text and a dropped index are not")
+ func unusedSkipsUniqueFullTextAndDropped() {
let found = unused(
- candidates: [("orders", "orders_note_idx"), ("orders", "orders_code_uniq"), ("orders", "dropped_idx")],
+ candidates: [
+ ("orders", "orders_note_idx"), ("orders", "orders_code_uniq"), ("orders", "dropped_idx"), ("orders", "orders_body_ft")
+ ],
indexes: [
+ IndexRow(index: "orders_body_ft", nonUnique: "1", type: "FULLTEXT", column: "body", collation: nil),
IndexRow(index: "orders_code_uniq", nonUnique: "0", column: "code"),
IndexRow(index: "orders_note_idx", nonUnique: "1", column: "note")
]
diff --git a/apple/scripts/probes/check-table-health-catalogs.sh b/apple/scripts/probes/check-table-health-catalogs.sh
index 1663ca4977..7f98fd43c2 100755
--- a/apple/scripts/probes/check-table-health-catalogs.sh
+++ b/apple/scripts/probes/check-table-health-catalogs.sh
@@ -287,8 +287,8 @@ SQL
local keys
keys="$(my "$DATABASE" -e "$(query_of "$source" foreignKeyQuery)")"
- expect "foreign key rows name both ends of a local key" "$keys" "$DATABASE child child_fk parent_id parent id 1 1" yes
- expect "foreign key rows include a key from another database into this one" "$keys" "$OTHER_DATABASE other_child other_child_fk parent_id parent id 0 1" yes
+ expect "foreign key rows name both ends of a local key" "$keys" "$DATABASE child child_fk parent_id parent id 1 1 1" yes
+ expect "foreign key rows include a key from another database into this one" "$keys" "$OTHER_DATABASE other_child other_child_fk parent_id parent id 1 0 1" yes
local nopk
nopk="$(my "$DATABASE" -e "$(query_of "$source" missingPrimaryKeyQuery)")"