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/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..bb74abcead 100644 --- a/apple/TablePro/Core/Services/Infrastructure/EditorTabOpener.swift +++ b/apple/TablePro/Core/Services/Infrastructure/EditorTabOpener.swift @@ -90,12 +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 .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+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..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 @@ -287,6 +288,8 @@ extension MainSplitViewController: NSMenuItemValidation { return context.isConnected && context.supportsUserManagement 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/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..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?, @@ -144,6 +156,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..fdeb5f3f91 --- /dev/null +++ b/apple/TablePro/Core/TableHealth/MySQLTableHealthProvider.swift @@ -0,0 +1,336 @@ +import Foundation + +// 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] + + 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. 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 + FROM performance_schema.table_io_waits_summary_by_index_usage u + WHERE u.OBJECT_SCHEMA = DATABASE() + 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 + """ + + // 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 + """ + + // 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_SCHEMA AS table_schema, + k.TABLE_NAME AS table_name, + k.CONSTRAINT_NAME AS constraint_name, + 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 + 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. + static let missingPrimaryKeyQuery = """ + SELECT t.TABLE_NAME AS table_name + FROM information_schema.TABLES t + 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_NAME + """ + + var statisticsQueries: [String] { + [Self.performanceSchemaQuery, Self.uptimeQuery, Self.usageCountingQuery] + } + + 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 [] + } + } + + 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]) + } + + 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 results: [QueryResult], + context: TableHealthQueryContext + ) -> [TableHealthFinding] { + switch check { + case .unusedIndex: + 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: + guard let result = results.first else { return [] } + return redundantIndexFindings(MySQLIndexShape.tables(from: TableHealthRow.rows(of: result)), context) + case .missingPrimaryKey: + guard let result = results.first else { return [] } + return TableHealthRow.rows(of: result).map { missingPrimaryKeyFinding($0["table_name"], context) } + case .bloat, .unindexedForeignKey: + return [] + } + } + + // 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], + 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, + index.type != "FULLTEXT", + !(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) + ) + } + } + + private func redundantIndexFindings( + _ tables: [MySQLIndexShape], + _ context: TableHealthQueryContext + ) -> [TableHealthFinding] { + var findings: [TableHealthFinding] = [] + 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: nil, table: table.name, objectName: index.name, + detail: String(format: String(localized: "%1$@ is covered by %2$@."), index.name, dominant.name), + fixSQL: dropIndexSQL(table: table.name, index: index.name, context) + )) + } + } + return findings + } + + 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: "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, from information_schema.STATISTICS rows ordered by table, index and position. +struct MySQLIndexShape { + struct Column: Equatable { + let name: String + let prefixLength: String + let sortOrder: String + } + + struct Index { + 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. + var hasExpression: Bool + + 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. 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, 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 + else { return false } + if columns.count < other.columns.count { return !isUnique } + 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 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"] + let tablePosition: Int + if let known = tablePositions[table] { + tablePosition = known + } else { + tablePosition = tables.count + tablePositions[table] = tablePosition + tables.append(MySQLIndexShape(name: table, indexes: [])) + } + 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"], + isVisible: row["is_visible"] != "NO" && row["ignored"] != "YES", + columns: [], + hasExpression: false + )) + } + let column = row["column_name"] + 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. Grouped by exact name, like the index rows. + static func foreignKeys(from rows: [TableHealthRow]) -> [String: [[String]]] { + struct Key { + let isLocal: Bool + let referencesHere: Bool + let table: String + let referencedTable: String + var parts: [(position: Int, column: String, referencedColumn: String)] + } + var keys: [Key] = [] + var keyPositions: [String: Int] = [:] + for row in rows { + 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( + isLocal: row["is_local"] == "1", referencesHere: row["references_here"] == "1", + 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(parts.map(\.column)) + } + if key.referencesHere { + byTable[key.referencedTable, default: []].append(parts.map(\.referencedColumn)) + } + } + return byTable + } +} diff --git a/apple/TablePro/Core/TableHealth/PostgreSQLTableHealthProvider.swift b/apple/TablePro/Core/TableHealth/PostgreSQLTableHealthProvider.swift new file mode 100644 index 0000000000..5d2081440f --- /dev/null +++ b/apple/TablePro/Core/TableHealth/PostgreSQLTableHealthProvider.swift @@ -0,0 +1,240 @@ +import Foundation + +// 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] + + 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 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 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, + 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 + 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 + """ + + // 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 + 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' + ) + 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 + """ + + // 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, 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 + 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 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 + 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 + """ + + var statisticsQueries: [String] { [Self.statisticsQuery] } + + 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 [] + } + } + + 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() } + + 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 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($0, context) } + case .unusedIndex: + return rows.map { unusedIndexFinding($0, context) } + case .missingPrimaryKey: + return rows.map { missingPrimaryKeyFinding($0, context) } + case .unindexedForeignKey: + return unindexedForeignKeyFindings(rows, context) + case .redundantIndex: + return [] + } + } + + 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"]) + 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: "VACUUM (ANALYZE) \(context.qualified(schema, table));" + ) + } + + 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: "DROP INDEX CONCURRENTLY \(context.qualified(schema, index));" + ) + } + + 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: "ALTER TABLE \(context.qualified(schema, table)) ADD PRIMARY KEY (column_name);" + ) + } + + 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 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"]] + )) + } + } + 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( + 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: "\(create) ON \(context.qualified(key.schema, key.table)) (\(columnList));" + ) + } + } +} diff --git a/apple/TablePro/Core/TableHealth/TableHealthProvider.swift b/apple/TablePro/Core/TableHealth/TableHealthProvider.swift new file mode 100644 index 0000000000..49342b60e2 --- /dev/null +++ b/apple/TablePro/Core/TableHealth/TableHealthProvider.swift @@ -0,0 +1,122 @@ +import Foundation +import TableProPluginKit + +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 } + var statisticsQueries: [String] { get } + 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 missing privilege say, leaves the others standing. + func fetchReport( + context: TableHealthQueryContext, + now: Date = Date(), + execute: (String) async throws -> QueryResult + ) async throws -> TableHealthReport { + 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) + answered[query] = .success(result) + return result + } catch let error as CancellationError { + throw error + } catch { + answered[query] = .failure(error) + throw error + } + } + + 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) + } + } + let statistics = statistics(from: statisticsResults, now: now) + + 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(database: context.database, checks: results, statisticsNote: statistics.note) + } +} + +enum TableHealthProviderFactory { + static func provider(for databaseType: DatabaseType) -> (any TableHealthProvider)? { + switch databaseType { + case .postgresql: + return PostgreSQLTableHealthProvider() + case .mysql, .mariadb: + return MySQLTableHealthProvider() + default: + return nil + } + } +} + +// 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 ?? "" + } +} + +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 49b0da031f..27fbb60c23 100644 --- a/apple/TablePro/Models/Query/QueryTabManager.swift +++ b/apple/TablePro/Models/Query/QueryTabManager.swift @@ -503,26 +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) + var newTab = QueryTab(title: WindowTitleResolver.title(for: tabType), tabType: tabType) newTab.tableContext.isEditable = false newTab.hasUserInteraction = true tabs.append(newTab) @@ -627,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( @@ -833,7 +807,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..cbf616086e --- /dev/null +++ b/apple/TablePro/Models/TableHealth/TableHealthModels.swift @@ -0,0 +1,87 @@ +import Foundation + +enum TableHealthCheckKind: String, Sendable { + case bloat + case unusedIndex + case redundantIndex + case missingPrimaryKey + case unindexedForeignKey + + 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, Sendable { + let kind: TableHealthCheckKind + // Nil where the engine has no schema level below the database. + let schema: String? + let table: String + let objectName: String? + let detail: String + let fixSQL: String + + var id: String { + [kind.rawValue, schema ?? "", table, objectName ?? ""].joined(separator: "\u{1F}") + } + + var qualifiedTable: String { + guard let schema else { return table } + return "\(schema).\(table)" + } +} + +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 outcome: Outcome + + var id: String { kind.rawValue } +} + +struct TableHealthReport: Sendable { + let database: String + var checks: [TableHealthCheckResult] + 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/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..6069c2a0f5 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" : { @@ -12266,6 +12284,9 @@ } } } + }, + "^[%lld finding](inflect: true)" : { + }, "^[%lld line](inflect: true)" : { "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" : { @@ -119717,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" : { @@ -126786,6 +126840,9 @@ } } } + }, + "Open Fix in Editor" : { + }, "Open in Agent Mode" : { "localizations" : { @@ -132507,6 +132564,9 @@ } } } + }, + "Performance Schema is off, so the server records no index reads." : { + }, "period" : { "extractionState" : "manual", @@ -143788,6 +143848,9 @@ }, "Redshift Endpoint" : { + }, + "Redundant Indexes" : { + }, "Ref Columns" : { "localizations" : { @@ -159651,6 +159714,9 @@ }, "Select a column" : { + }, + "Select a database in the sidebar to check its tables." : { + }, "Select a definition to edit it" : { @@ -159684,6 +159750,9 @@ } } } + }, + "Select a finding to preview the SQL that fixes it." : { + }, "Select a Library" : { "localizations" : { @@ -177746,6 +177815,15 @@ } } } + }, + "Table Health" : { + + }, + "Table health is available for PostgreSQL, MySQL and MariaDB." : { + + }, + "Table Health Not Available" : { + }, "Table Info" : { "extractionState" : "stale", @@ -180702,6 +180780,9 @@ } } } + }, + "Tables Without a Primary Key" : { + }, "Tables, views, and functions owned by this role will be deleted." : { "localizations" : { @@ -186684,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", @@ -193200,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", @@ -199130,6 +199217,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" : { @@ -202340,6 +202430,9 @@ } } } + }, + "track_counts is off, so the server records no table or index activity." : { + }, "Tradeoffs" : { "localizations" : { @@ -206705,6 +206798,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..13a31624d1 --- /dev/null +++ b/apple/TablePro/ViewModels/TableHealthViewModel.swift @@ -0,0 +1,102 @@ +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 { + nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "TableHealth") + + let provider: (any 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 connectionId: UUID + private let metadata: any ScopedMetadataProviding + private var refreshTask: Task? + + var isSupported: Bool { provider != nil } + + var selectedFinding: TableHealthFinding? { + selectedFindingId.flatMap { report?.finding(id: $0) } + } + + init( + connectionId: UUID, + provider: (any TableHealthProvider)?, + metadata: any ScopedMetadataProviding = DatabaseManager.shared + ) { + self.connectionId = connectionId + self.provider = provider + self.metadata = metadata + } + + // A pane switch restarts `.task`, so only the first appearance loads. + func loadIfNeeded() async { + guard report == nil, loadError == nil else { return } + 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 { + 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 + refreshTask = nil + } + + do { + 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) + } + } + report = Self.merging(fresh, over: report) + loadError = nil + lastRefreshDate = Date() + if selectedFinding == nil { selectedFindingId = nil } + } catch { + Self.logger.warning("Table health refresh failed: \(error.publicLogShape, privacy: .public)") + 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, previous.database == fresh.database else { return fresh } + var merged = fresh + 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 1fdd1164aa..458ab4de9a 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: @@ -277,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, @@ -299,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) @@ -314,6 +324,47 @@ 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, databaseType: connection.type, 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) + .onAppear { + guard tableHealthViewModels[tab.id] == nil else { return } + tableHealthViewModels[tab.id] = TableHealthViewModel( + connectionId: connection.id, + provider: TableHealthProviderFactory.provider(for: connection.type) + ) + } + } + } + .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 @@ -344,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+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 785c076147..5500dd2acf 100644 --- a/apple/TablePro/Views/Main/MainContentCommandActions+DatabaseTools.swift +++ b/apple/TablePro/Views/Main/MainContentCommandActions+DatabaseTools.swift @@ -24,11 +24,15 @@ extension MainContentCommandActions { } func showServerDashboard() { - coordinator?.showServerDashboard() + coordinator?.showSingletonTab(.serverDashboard) } func showQueryInsights() { - coordinator?.showQueryInsights() + coordinator?.showSingletonTab(.insights) + } + + func showTableHealth() { + coordinator?.showSingletonTab(.tableHealth) } var supportsServerDashboard: Bool { @@ -36,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 new file mode 100644 index 0000000000..da5705215e --- /dev/null +++ b/apple/TablePro/Views/TableHealth/TableHealthView.swift @@ -0,0 +1,223 @@ +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 + let databaseType: DatabaseType + let actions: TableHealthActions + + // 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 { + header + } + content + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .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 viewModel.report != nil { + AutosavingSplitView( + autosaveName: "com.TablePro.tableHealth.findingsDetail", + isVertical: false, + primaryMinimum: 160, + secondaryMinimum: 150, + primaryHoldingPriority: .splitPaneFlexible + ) { + TableHealthFindingsList(viewModel: viewModel, actions: actions) + } secondary: { + TableHealthDetailPane(viewModel: viewModel, databaseType: databaseType, actions: actions) + } + } 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) + } + } + + private var header: some View { + VStack(spacing: 0) { + HStack(spacing: 8) { + if let report = viewModel.report { + Text(verbatim: report.database) + .font(.callout.weight(.semibold)) + Text("^[\(report.findingCount) finding](inflect: true)") + .font(.callout) + .monospacedDigit() + if let error = viewModel.loadError { + Image(systemName: "exclamationmark.triangle") + .foregroundStyle(.secondary) + .help(error) + .accessibilityLabel(error) + } + } + + Spacer(minLength: 8) + + PaneRefreshControls( + isRefreshing: viewModel.isRefreshing, + lastRefreshDate: viewModel.lastRefreshDate, + accessibilityIdentifier: "table-health-refresh-button" + ) { + Task { await viewModel.refresh() } + } + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + + Divider() + } + .paneBarBackground() + } +} + +private struct TableHealthFindingsList: View { + @ObservedObject var viewModel: TableHealthViewModel + let actions: TableHealthActions + + 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 { + 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, 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) + } + } + } + } + + @ViewBuilder + 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) + } + } + 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) + } + } + + private func status(_ message: String, systemImage: String) -> some View { + Label { + Text(verbatim: message) + } icon: { + Image(systemName: systemImage) + } + .font(.callout) + .foregroundStyle(.secondary) + } + + 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 databaseType: DatabaseType + 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: finding.qualifiedTable) + .font(.headline) + .lineLimit(1) + .truncationMode(.middle) + Spacer(minLength: 8) + 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) } + } + 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) + } + .padding(12) + } else { + Text("Select a finding to preview the SQL that fixes it.") + .font(.callout) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } +} 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/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/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 new file mode 100644 index 0000000000..4841190650 --- /dev/null +++ b/apple/TableProTests/Core/TableHealth/MySQLTableHealthProviderTests.swift @@ -0,0 +1,423 @@ +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +struct MySQLTableHealthProviderTests { + private let provider = MySQLTableHealthProvider() + private let context = TableHealthFixture.mysqlContext() + + 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? + var position: Int? + } + + // The columns a SELECT * on STATISTICS returns, as MySQL 8 names them. + private static let indexColumns = [ + "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 { + var seen: [String: Int] = [:] + return TableHealthFixture.result( + columns: indexColumns, + rows: rows.map { row -> [String?] in + 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 + ] + } + ) + } + + private func redundant(_ rows: [IndexRow]) -> [String: String] { + var map: [String: String] = [:] + for finding in provider.findings(for: .redundantIndex, from: [Self.indexResult(rows)], context: context) { + map[finding.objectName ?? ""] = finding.detail + } + return map + } + + private struct KeyRow { + var schema = "shop" + let table: String + let constraint: String + let column: 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", "ordinal_position", "is_local", "references_here" + ] + + private func unused( + candidates: [(table: String, index: String)], + indexes: [IndexRow], + foreignKeys: [KeyRow] = [] + ) -> [String] { + var seen: [String: Int] = [:] + 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: 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, + String(seen[identity, default: 1]), key.isLocal ? "1" : "0", key.referencesHere ? "1" : "0" + ] + } + ) + ] + 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([ + 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) + } + + @Test("A shorter unique index enforces more than the longer one, so it stays") + func shorterUniqueStays() { + 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([ + 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([ + 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([ + 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([ + 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 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), + IndexRow(index: "ft_ab", nonUnique: "1", type: "FULLTEXT", column: "b", collation: 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(identical.isEmpty) + } + + @Test("A key part sorted the other way keeps an index apart") + func sortDirectionMatters() { + 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", "SEQ_IN_INDEX", "NON_UNIQUE", "INDEX_TYPE", "COLUMN_NAME", "SUB_PART", "COLLATION", + "IGNORED" + ], + rows: [ + ["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 + ) + #expect(invisible.isEmpty) + #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([ + 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, 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"), ("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") + ] + ) + #expect(found == ["orders_note_idx"]) + } + + @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: [ + 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, from this database or another, is not unused") + func unusedSkipsReferencedIndex() { + let found = unused( + 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( + 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 == []) + } + + @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") + func unusedAndMissingKeyFixes() throws { + 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: Self.keyColumns, 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);") + } + + @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("While reads are counted, the note names the server start") + func serverStartNote() { + let start = Date(timeIntervalSince1970: 1_800_000_000 - 3600).formatted(date: .abbreviated, time: .shortened) + #expect(statistics().note?.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() { + 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 new file mode 100644 index 0000000000..63ae179304 --- /dev/null +++ b/apple/TableProTests/Core/TableHealth/PostgreSQLTableHealthProviderTests.swift @@ -0,0 +1,198 @@ +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() + 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() { + #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 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"]] + ).first) + + #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 finding = try #require(findings( + .bloat, + columns: ["schema_name", "table_name", "live_rows", "dead_rows", "last_vacuum"], + rows: [["public", "orders", "2000", "3000", nil]] + ).first) + #expect(finding.detail.contains("Never vacuumed")) + } + + @Test("An unused index previews a concurrent drop") + func unusedIndexFinding() throws { + let finding = try #require(findings( + .unusedIndex, + columns: ["schema_name", "table_name", "index_name", "index_bytes"], + rows: [["sales", "orders", "orders_note_idx", "8192"]] + ).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 finding = try #require(findings( + .missingPrimaryKey, + columns: ["schema_name", "table_name"], + rows: [["public", #"Odd "Name""#]] + ).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() { + let found = findings( + .unindexedForeignKey, + columns: ["schema_name", "table_name", "table_kind", "constraint_name", "column_name"], + rows: [ + ["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"] + ] + ) + + #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 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"]] + ).map(\.id) + #expect(Set(ids).count == 3) + } + + @Test("The note names when statistics were reset, or that they never were") + func statisticsNote() { + #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()) + } + + @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(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.empty() + } + + #expect(report.database == "shop") + #expect(report.checks.map(\.kind) == [.bloat, .unusedIndex, .missingPrimaryKey, .unindexedForeignKey]) + #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("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(context: context) { _ in throw CancellationError() } + } + } + + @Test("Every query the report runs only reads") + func queriesOnlyRead() { + 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 new file mode 100644 index 0000000000..a621a90117 --- /dev/null +++ b/apple/TableProTests/Core/TableHealth/TableHealthTestSupport.swift @@ -0,0 +1,28 @@ +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 + ) + } + + 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 new file mode 100644 index 0000000000..c0a767b439 --- /dev/null +++ b/apple/TableProTests/ViewModels/TableHealthViewModelTests.swift @@ -0,0 +1,258 @@ +import Foundation +@testable import TablePro +import TableProPluginKit +import TableProTestSupport +import Testing + +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 + // 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( + scope: DatabaseScope, + workload: MetadataConnectionPool.Workload, + _ 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))) + } + + func browseScope(for connectionId: UUID) -> DatabaseScope? { + guard isConnected else { return nil } + return DatabaseScope(connectionId: connectionId, database: database, schema: "public") + } +} + +@MainActor +struct TableHealthViewModelTests { + private func makeViewModel( + _ metadata: HealthMetadata, + databaseType: DatabaseType = .postgresql + ) -> TableHealthViewModel { + TableHealthViewModel( + connectionId: metadata.connection.id, + provider: TableHealthProviderFactory.provider(for: databaseType), + metadata: metadata + ) + } + + @Test("An engine without a provider is not supported and reads nothing") + func unsupportedEngine() async { + let metadata = HealthMetadata() + let viewModel = makeViewModel(metadata, databaseType: .sqlite) + + await viewModel.refresh() + + #expect(!viewModel.isSupported) + #expect(viewModel.report == nil) + #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 metadata = HealthMetadata() + let viewModel = makeViewModel(metadata) + + await viewModel.loadIfNeeded() + await viewModel.loadIfNeeded() + + #expect(metadata.leases.count == 1) + #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() + 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 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) + + metadata.leaseFails = false + 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 metadata = HealthMetadata() + let viewModel = makeViewModel(metadata) + await viewModel.refresh() + + metadata.isConnected = false + await viewModel.refresh() + + #expect(viewModel.report?.findingCount == 1) + #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 metadata = HealthMetadata() + let viewModel = makeViewModel(metadata) + await viewModel.refresh() + let logs = try #require(viewModel.report?.checks.flatMap { $0.findings }.first) + viewModel.selectedFindingId = logs.id + + metadata.server.failsMissingKeyCheck = true + await viewModel.refresh() + + let check = try #require(viewModel.report?.checks.first { $0.kind == .missingPrimaryKey }) + #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 metadata = HealthMetadata() + let viewModel = makeViewModel(metadata) + await viewModel.refresh() + + metadata.database = "archive" + metadata.server.failsMissingKeyCheck = true + await viewModel.refresh() + + #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 metadata = HealthMetadata() + metadata.server.tablesWithoutKey = ["logs", "events"] + let viewModel = makeViewModel(metadata) + await viewModel.refresh() + + 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") + + 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 new file mode 100644 index 0000000000..7fc59675af --- /dev/null +++ b/apple/changelog.d/3958.added.md @@ -0,0 +1 @@ +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 new file mode 100755 index 0000000000..7f98fd43c2 --- /dev/null +++ b/apple/scripts/probes/check-table-health-catalogs.sh @@ -0,0 +1,322 @@ +#!/usr/bin/env bash +# +# Check the Table Health queries against real PostgreSQL, MySQL and MariaDB servers. +# +# 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 +# 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" +OTHER_DATABASE="tablepro_health_other" +FAILURES=0 + +# 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")" + [ -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" found=no + grep -qF -- "$needle" <<< "$output" && found=yes + if [ "$found" = "$wanted" ]; then + echo "ok $label" + else + echo "FAIL $label: expected '$needle' present=$wanted" >&2 + FAILURES=$((FAILURES + 1)) + fi +} + +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"' 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 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 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 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)); +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; +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 $(seq 1 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 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 + 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 + fi + + 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 + 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|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 + 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 + 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 + + # 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 + 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 + 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 + + kill "$holder" 2> /dev/null + wait "$holder" 2> /dev/null +} + +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) + # 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" + # 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 + + # 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); +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)")" + echo "ok performance_schema = $ps" + local uptime + uptime="$(my "$DATABASE" -e "$(query_of "$source" uptimeQuery)")" + expect "uptime reads as Uptime and a value" "$uptime" "Uptime" yes + + local counting + counting="$(my "$DATABASE" -e "$(query_of "$source" usageCountingQuery)")" + if [ "$ps" = "1" ]; then + 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 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 + 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)" + 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 + + 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 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)")" + 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 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 "$DATABASE" -e "$(query_of "$source" missingPrimaryKeyQuery)")" + expect "the previewed primary key clears its finding" "$nopk" "odd\`name" 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 + 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"