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)")"