diff --git a/CHANGELOG.md b/CHANGELOG.md index 16552ef66..a285c4443 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,10 +9,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- MySQL `EXPLAIN FORMAT=TREE` and `EXPLAIN ANALYZE` output now renders as a visual plan diagram or tree instead of raw text only. +- A query plan now opens as a result tab next to your query results, so you can switch back to the data without re-running the query, and pin a plan to keep it. +- EXPLAIN plan diagrams zoom with a trackpad pinch, a two-finger double tap, or Cmd and scroll, and the controls gained fit-to-window. Plans can be copied or exported as a PNG. +- The plan tree now has resizable, sortable Operation, Cost, Rows and Actual Time columns, arrow-key navigation, a right-click menu to copy a step, and a detail panel you can resize. +- PGlite, Cloudflare D1, libSQL and Turso query plans now render as a diagram and tree instead of raw text. +- Plan steps show a cost badge whose shape and colour escalate together, so cost reads without relying on colour. +- The ER diagram now uses the same zoom and scrolling as the plan diagram, gaining two-finger double tap to zoom, Cmd and scroll, and standard scrollers. - PostgreSQL array columns of a simple type, including arrays of an enum, get a list editor in the data grid. One row per element, with reordering, add and remove, and NULL per element. An empty array and a NULL column stay separate values. Enum arrays pick from the labels the type declares. Arrays of `jsonb`, `bytea` or composite types, and multi-dimensional values, keep the plain text editor. ### Fixed +- Running EXPLAIN from the toolbar now asks for confirmation when Safe Mode requires it. It previously skipped that check on every database that offers an EXPLAIN variant, even though `EXPLAIN ANALYZE` runs the query. +- The Stop button can now cancel a running `EXPLAIN ANALYZE`. +- An EXPLAIN that fails now reports the error in the usual place instead of showing it where the plan should be. +- Clear Query now clears the query plan too, instead of leaving a stale plan on screen. +- EXPLAIN runs started from the toolbar are now recorded in Query History. +- A query plan that cannot be read as a tree now says so and shows the raw output, instead of leaving an empty pane. +- EXPLAIN plan diagrams line every node of the same depth up on one row, so a tall box no longer pushes its children up into itself. +- Running a second EXPLAIN in the same tab redraws the diagram instead of leaving the previous plan on screen. +- Plans that report a cost per node but no startup cost, such as MySQL's, now show that cost in the diagram and the tree. +- Nodes in a plan whose root reports no cost are no longer all painted red. - PostgreSQL enum columns whose type lives in another schema now show their values instead of a plain text box. - Select several databases or schemas in the sidebar tree and act on them at once: drop, refresh, copy names, or export. Shift-click and Cmd-click extend the selection. diff --git a/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift b/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift index 203fa8d1f..c38c177e2 100644 --- a/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift +++ b/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift @@ -22,11 +22,11 @@ final class ClickHousePlugin: NSObject, TableProPlugin, DriverPlugin { static let isDownloadable = true static let explainVariants: [ExplainVariant] = [ - ExplainVariant(id: "plan", label: "Plan", sqlPrefix: "EXPLAIN"), - ExplainVariant(id: "pipeline", label: "Pipeline", sqlPrefix: "EXPLAIN PIPELINE"), - ExplainVariant(id: "ast", label: "AST", sqlPrefix: "EXPLAIN AST"), - ExplainVariant(id: "syntax", label: "Syntax", sqlPrefix: "EXPLAIN SYNTAX"), - ExplainVariant(id: "estimate", label: "Estimate", sqlPrefix: "EXPLAIN ESTIMATE"), + ExplainVariant(id: "plan", label: "Plan", sqlPrefix: "EXPLAIN", format: .indentedText), + ExplainVariant(id: "pipeline", label: "Pipeline", sqlPrefix: "EXPLAIN PIPELINE", format: .indentedText), + ExplainVariant(id: "ast", label: "AST", sqlPrefix: "EXPLAIN AST", format: .indentedText), + ExplainVariant(id: "syntax", label: "Syntax", sqlPrefix: "EXPLAIN SYNTAX", format: .indentedText), + ExplainVariant(id: "estimate", label: "Estimate", sqlPrefix: "EXPLAIN ESTIMATE", format: .indentedText), ] static let brandColorHex = "#FFD100" static let postConnectActions: [PostConnectAction] = [.selectDatabaseFromLastSession] diff --git a/Plugins/CloudflareD1DriverPlugin/CloudflareD1Plugin.swift b/Plugins/CloudflareD1DriverPlugin/CloudflareD1Plugin.swift index 203930317..d7cba31dd 100644 --- a/Plugins/CloudflareD1DriverPlugin/CloudflareD1Plugin.swift +++ b/Plugins/CloudflareD1DriverPlugin/CloudflareD1Plugin.swift @@ -33,7 +33,9 @@ final class CloudflareD1Plugin: NSObject, TableProPlugin, DriverPlugin { static let urlSchemes: [String] = ["d1"] static let explainVariants: [ExplainVariant] = [ - ExplainVariant(id: "plan", label: "Query Plan", sqlPrefix: "EXPLAIN QUERY PLAN") + ExplainVariant( + id: "plan", label: "Query Plan", sqlPrefix: "EXPLAIN QUERY PLAN", format: .sqliteQueryPlan + ) ] static let structureColumnFields: [StructureColumnField] = [.name, .type, .nullable, .defaultValue] diff --git a/Plugins/LibSQLDriverPlugin/LibSQLPlugin.swift b/Plugins/LibSQLDriverPlugin/LibSQLPlugin.swift index dd5b72a5c..3658ce525 100644 --- a/Plugins/LibSQLDriverPlugin/LibSQLPlugin.swift +++ b/Plugins/LibSQLDriverPlugin/LibSQLPlugin.swift @@ -37,7 +37,9 @@ final class LibSQLPlugin: NSObject, TableProPlugin, DriverPlugin { static let urlSchemes: [String] = ["libsql"] static let explainVariants: [ExplainVariant] = [ - ExplainVariant(id: "plan", label: "Query Plan", sqlPrefix: "EXPLAIN QUERY PLAN") + ExplainVariant( + id: "plan", label: "Query Plan", sqlPrefix: "EXPLAIN QUERY PLAN", format: .sqliteQueryPlan + ) ] static let structureColumnFields: [StructureColumnField] = [.name, .type, .nullable, .defaultValue] diff --git a/Plugins/MySQLDriverPlugin/MySQLPlugin.swift b/Plugins/MySQLDriverPlugin/MySQLPlugin.swift index 1ed5c077e..e548405e4 100644 --- a/Plugins/MySQLDriverPlugin/MySQLPlugin.swift +++ b/Plugins/MySQLDriverPlugin/MySQLPlugin.swift @@ -30,8 +30,13 @@ final class MySQLPlugin: NSObject, TableProPlugin, DriverPlugin { static let urlSchemes: [String] = ["mysql"] static let explainVariants: [ExplainVariant] = [ - ExplainVariant(id: "explain", label: "EXPLAIN", sqlPrefix: "EXPLAIN"), - ExplainVariant(id: "explain-json", label: "EXPLAIN (JSON)", sqlPrefix: "EXPLAIN FORMAT=JSON"), + ExplainVariant(id: "explain", label: "EXPLAIN", sqlPrefix: "EXPLAIN", format: .mysqlComposite), + ExplainVariant( + id: "explain-json", + label: "EXPLAIN (JSON)", + sqlPrefix: "EXPLAIN FORMAT=JSON", + format: .mysqlComposite + ), ] static let brandColorHex = "#FF9500" static let postConnectActions: [PostConnectAction] = [.selectDatabaseFromLastSession] diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPlugin.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPlugin.swift index a3544d113..d7c759765 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPlugin.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPlugin.swift @@ -50,8 +50,15 @@ final class PostgreSQLPlugin: NSObject, TableProPlugin, DriverPlugin { static let supportsSchemaSwitching = true static let postConnectActions: [PostConnectAction] = [.selectSchemaFromLastSession] static let explainVariants: [ExplainVariant] = [ - ExplainVariant(id: "explain", label: "EXPLAIN", sqlPrefix: "EXPLAIN (FORMAT JSON)"), - ExplainVariant(id: "analyze", label: "EXPLAIN ANALYZE", sqlPrefix: "EXPLAIN (ANALYZE, FORMAT JSON)"), + ExplainVariant( + id: "explain", label: "EXPLAIN", sqlPrefix: "EXPLAIN (FORMAT JSON)", format: .postgresJson + ), + ExplainVariant( + id: "analyze", + label: "EXPLAIN ANALYZE", + sqlPrefix: "EXPLAIN (ANALYZE, FORMAT JSON)", + format: .postgresJson + ), ] static let databaseGroupingStrategy: GroupingStrategy = .bySchema static let columnTypesByCategory: [String: [String]] = [ diff --git a/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift b/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift index 1c8e84cf1..4e48aada1 100644 --- a/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift +++ b/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift @@ -15,7 +15,9 @@ final class SQLitePlugin: NSObject, TableProPlugin, DriverPlugin { static let capabilities: [PluginCapability] = [.databaseDriver] static let explainVariants: [ExplainVariant] = [ - ExplainVariant(id: "explain", label: "Explain", sqlPrefix: "EXPLAIN QUERY PLAN") + ExplainVariant( + id: "explain", label: "Explain", sqlPrefix: "EXPLAIN QUERY PLAN", format: .sqliteQueryPlan + ) ] static let databaseTypeId = "SQLite" diff --git a/Plugins/TableProPluginKit/ExplainPlanFormat.swift b/Plugins/TableProPluginKit/ExplainPlanFormat.swift new file mode 100644 index 000000000..fc7c0a808 --- /dev/null +++ b/Plugins/TableProPluginKit/ExplainPlanFormat.swift @@ -0,0 +1,22 @@ +import Foundation + +/// The shape of the text a database returns for an EXPLAIN variant. String-based rather than an +/// enum so a plugin can name a format the app does not know yet without a PluginKit release. +public struct ExplainPlanFormat: Hashable, Sendable { + public let rawValue: String + + public init(rawValue: String) { + self.rawValue = rawValue + } +} + +public extension ExplainPlanFormat { + /// Output the app has no structured parser for. Rendered as text. + static let plainText = ExplainPlanFormat(rawValue: "plainText") + + static let postgresJson = ExplainPlanFormat(rawValue: "postgresJson") + static let mysqlComposite = ExplainPlanFormat(rawValue: "mysqlComposite") + static let sqliteQueryPlan = ExplainPlanFormat(rawValue: "sqliteQueryPlan") + static let cockroachText = ExplainPlanFormat(rawValue: "cockroachText") + static let indentedText = ExplainPlanFormat(rawValue: "indentedText") +} diff --git a/Plugins/TableProPluginKit/ExplainVariant.swift b/Plugins/TableProPluginKit/ExplainVariant.swift index 3edd0ab50..97e47dba7 100644 --- a/Plugins/TableProPluginKit/ExplainVariant.swift +++ b/Plugins/TableProPluginKit/ExplainVariant.swift @@ -4,10 +4,17 @@ public struct ExplainVariant: Sendable, Identifiable { public let id: String public let label: String public let sqlPrefix: String + public let format: ExplainPlanFormat - public init(id: String, label: String, sqlPrefix: String) { + public init(id: String, label: String, sqlPrefix: String, format: ExplainPlanFormat = .plainText) { self.id = id self.label = label self.sqlPrefix = sqlPrefix + self.format = format + } + + @_disfavoredOverload + public init(id: String, label: String, sqlPrefix: String) { + self.init(id: id, label: label, sqlPrefix: sqlPrefix, format: .plainText) } } diff --git a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift index eca153462..9e87d34af 100644 --- a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift +++ b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift @@ -85,10 +85,16 @@ extension QueryExecutionCoordinator { ) { guard let idx = parent.tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { return } - if let planText = ExplainResultRouter.planText(sql: sql, columns: columns, rows: rows) { + if let routed = ExplainResultRouter.route( + sql: sql, + columns: columns, + rows: rows, + databaseType: conn.type, + declaredVariants: conn.type.explainVariants + ) { applyExplainResult( tabId: tabId, - planText: planText, + routed: routed, executionTime: executionTime, rowCount: rows.count, sql: sql, @@ -231,23 +237,24 @@ extension QueryExecutionCoordinator { private func applyExplainResult( tabId: UUID, - planText: String, + routed: ExplainResultRouter.RoutedPlan, executionTime: TimeInterval, rowCount: Int, sql: String, connection conn: DatabaseConnection, queryParameterValues: [QueryParameter]? ) { - let plan = QueryPlanParserFactory.parser(for: conn.type)?.parse(rawText: planText) - parent.tabManager.mutate(tabId: tabId) { tab in tab.execution.executionTime = executionTime tab.execution.rowsAffected = 0 tab.execution.statusMessage = nil tab.execution.lastExecutedAt = Date() - tab.display.explainText = planText - tab.display.explainPlan = plan - tab.display.explainExecutionTime = executionTime + tab.pagination.resetLoadMore() + tab.display.replaceUnpinnedResults( + with: [ExplainResultSetFactory.make( + rawText: routed.rawText, plan: routed.plan, sql: sql, executionTime: executionTime + )] + ) if tab.display.isResultsCollapsed { tab.display.isResultsCollapsed = false } diff --git a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Parameters.swift b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Parameters.swift index d7a070b94..059820af1 100644 --- a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Parameters.swift +++ b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Parameters.swift @@ -99,8 +99,6 @@ extension QueryExecutionCoordinator { parent.tabManager.mutate(at: index) { tab in tab.execution.executionTime = nil tab.execution.errorMessage = nil - tab.display.explainText = nil - tab.display.explainPlan = nil } let tab = parent.tabManager.tabs[index] parent.toolbarState.setExecuting(true) diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift index ea2541e55..6014deb47 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift @@ -518,11 +518,17 @@ extension PluginMetadataRegistry { requiresAuthentication: true, supportsForeignKeys: false, supportsSchemaEditing: true, isDownloadable: true, primaryUrlScheme: "clickhouse", parameterStyle: .questionMark, navigationModel: .standard, explainVariants: [ - ExplainVariant(id: "plan", label: "Plan", sqlPrefix: "EXPLAIN"), - ExplainVariant(id: "pipeline", label: "Pipeline", sqlPrefix: "EXPLAIN PIPELINE"), - ExplainVariant(id: "ast", label: "AST", sqlPrefix: "EXPLAIN AST"), - ExplainVariant(id: "syntax", label: "Syntax", sqlPrefix: "EXPLAIN SYNTAX"), - ExplainVariant(id: "estimate", label: "Estimate", sqlPrefix: "EXPLAIN ESTIMATE") + ExplainVariant(id: "plan", label: "Plan", sqlPrefix: "EXPLAIN", format: .indentedText), + ExplainVariant( + id: "pipeline", label: "Pipeline", sqlPrefix: "EXPLAIN PIPELINE", format: .indentedText + ), + ExplainVariant(id: "ast", label: "AST", sqlPrefix: "EXPLAIN AST", format: .indentedText), + ExplainVariant( + id: "syntax", label: "Syntax", sqlPrefix: "EXPLAIN SYNTAX", format: .indentedText + ), + ExplainVariant( + id: "estimate", label: "Estimate", sqlPrefix: "EXPLAIN ESTIMATE", format: .indentedText + ) ], pathFieldRole: .database, supportsHealthMonitor: true, urlSchemes: ["clickhouse", "ch"], postConnectActions: [.selectDatabaseFromLastSession], @@ -574,7 +580,7 @@ extension PluginMetadataRegistry { isDownloadable: true, primaryUrlScheme: "duckdb", parameterStyle: .dollar, navigationModel: .standard, explainVariants: [ - ExplainVariant(id: "explain", label: "EXPLAIN", sqlPrefix: "EXPLAIN"), + ExplainVariant(id: "explain", label: "EXPLAIN", sqlPrefix: "EXPLAIN", format: .indentedText), ], pathFieldRole: .database, supportsHealthMonitor: false, urlSchemes: ["duckdb", "quack"], postConnectActions: [], @@ -918,7 +924,9 @@ extension PluginMetadataRegistry { requiresAuthentication: true, supportsForeignKeys: true, supportsSchemaEditing: false, isDownloadable: true, primaryUrlScheme: "d1", parameterStyle: .questionMark, navigationModel: .standard, explainVariants: [ - ExplainVariant(id: "plan", label: "Query Plan", sqlPrefix: "EXPLAIN QUERY PLAN") + ExplainVariant( + id: "plan", label: "Query Plan", sqlPrefix: "EXPLAIN QUERY PLAN", format: .sqliteQueryPlan + ) ], pathFieldRole: .database, supportsHealthMonitor: true, urlSchemes: ["d1"], postConnectActions: [], @@ -976,7 +984,9 @@ extension PluginMetadataRegistry { requiresAuthentication: false, supportsForeignKeys: true, supportsSchemaEditing: true, isDownloadable: true, primaryUrlScheme: "libsql", parameterStyle: .questionMark, navigationModel: .standard, explainVariants: [ - ExplainVariant(id: "plan", label: "Query Plan", sqlPrefix: "EXPLAIN QUERY PLAN") + ExplainVariant( + id: "plan", label: "Query Plan", sqlPrefix: "EXPLAIN QUERY PLAN", format: .sqliteQueryPlan + ) ], pathFieldRole: .database, supportsHealthMonitor: true, urlSchemes: ["libsql"], postConnectActions: [], diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry.swift b/TablePro/Core/Plugins/PluginMetadataRegistry.swift index 979bf5840..b9e36dd25 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry.swift @@ -730,8 +730,15 @@ final class PluginMetadataRegistry: @unchecked Sendable { isDownloadable: false, primaryUrlScheme: "cockroachdb", parameterStyle: .dollar, navigationModel: .standard, explainVariants: [ - ExplainVariant(id: "explain", label: "EXPLAIN", sqlPrefix: "EXPLAIN"), - ExplainVariant(id: "analyze", label: "EXPLAIN ANALYZE", sqlPrefix: "EXPLAIN ANALYZE"), + ExplainVariant( + id: "explain", label: "EXPLAIN", sqlPrefix: "EXPLAIN", format: .cockroachText + ), + ExplainVariant( + id: "analyze", + label: "EXPLAIN ANALYZE", + sqlPrefix: "EXPLAIN ANALYZE", + format: .cockroachText + ), ], pathFieldRole: .database, supportsHealthMonitor: true, urlSchemes: ["cockroachdb", "cockroach"], diff --git a/TablePro/Core/Services/Query/ExplainFormatResolver.swift b/TablePro/Core/Services/Query/ExplainFormatResolver.swift new file mode 100644 index 000000000..00c28b06c --- /dev/null +++ b/TablePro/Core/Services/Query/ExplainFormatResolver.swift @@ -0,0 +1,38 @@ +// +// ExplainFormatResolver.swift +// TablePro +// +// Resolves which plan format a piece of EXPLAIN output is in, for both the Explain action +// (where the variant is known) and a hand-typed statement (where only the SQL is). +// + +import Foundation +import TableProPluginKit + +enum ExplainFormatResolver { + static func resolve(declared: ExplainPlanFormat, databaseType: DatabaseType) -> ExplainPlanFormat { + guard declared == .plainText else { return declared } + return ExplainPlanFormatDefaults.format(for: databaseType) + } + + static func resolve( + sql: String, + databaseType: DatabaseType, + declaredVariants: [ExplainVariant] + ) -> ExplainPlanFormat { + let declared = matchingVariant(sql: sql, declaredVariants: declaredVariants)?.format ?? .plainText + return resolve(declared: declared, databaseType: databaseType) + } + + /// The declared variant whose SQL prefix the statement starts with. Longest prefix wins so + /// `EXPLAIN FORMAT=JSON ...` matches the JSON variant rather than the bare `EXPLAIN` one. + static func matchingVariant(sql: String, declaredVariants: [ExplainVariant]) -> ExplainVariant? { + let normalized = sql.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalized.isEmpty else { return nil } + + return declaredVariants + .filter { !$0.sqlPrefix.isEmpty } + .filter { normalized.range(of: $0.sqlPrefix, options: [.caseInsensitive, .anchored]) != nil } + .max { $0.sqlPrefix.count < $1.sqlPrefix.count } + } +} diff --git a/TablePro/Core/Services/Query/ExplainPlanFormatDefaults.swift b/TablePro/Core/Services/Query/ExplainPlanFormatDefaults.swift new file mode 100644 index 000000000..71433da72 --- /dev/null +++ b/TablePro/Core/Services/Query/ExplainPlanFormatDefaults.swift @@ -0,0 +1,30 @@ +// +// ExplainPlanFormatDefaults.swift +// TablePro +// +// The plan format the app assumes for a database type when the driver's declared variant +// does not name one. A plugin built before ExplainVariant carried a format still reports +// .plainText, so without this table its plans would render as raw text until it is rebuilt. +// + +import Foundation +import TableProPluginKit + +enum ExplainPlanFormatDefaults { + static func format(for databaseType: DatabaseType) -> ExplainPlanFormat { + switch databaseType { + case .postgresql, .redshift, .pglite: + return .postgresJson + case .mysql, .mariadb: + return .mysqlComposite + case .sqlite, .cloudflareD1, .libsql, .turso: + return .sqliteQueryPlan + case .cockroachdb: + return .cockroachText + case .clickhouse, .duckdb: + return .indentedText + default: + return .plainText + } + } +} diff --git a/TablePro/Core/Services/Query/ExplainPlanParserRegistry.swift b/TablePro/Core/Services/Query/ExplainPlanParserRegistry.swift new file mode 100644 index 000000000..96ac68024 --- /dev/null +++ b/TablePro/Core/Services/Query/ExplainPlanParserRegistry.swift @@ -0,0 +1,34 @@ +// +// ExplainPlanParserRegistry.swift +// TablePro +// +// Maps a plan format to the parser that reads it. Keyed by format rather than database type so +// engines that share an output shape share a parser, and a plugin can name a format the app +// does not parse yet without breaking. +// + +import Foundation +import TableProPluginKit + +enum ExplainPlanParserRegistry { + static func parser(for format: ExplainPlanFormat) -> QueryPlanParser? { + switch format { + case .postgresJson: + return PostgreSQLPlanParser() + case .mysqlComposite: + return MySQLPlanParser() + case .sqliteQueryPlan: + return SQLitePlanParser() + case .cockroachText: + return CockroachDBPlanParser() + case .indentedText: + return IndentedTextPlanParser() + default: + return nil + } + } + + static func plan(from rawText: String, format: ExplainPlanFormat) -> QueryPlan? { + parser(for: format)?.parse(rawText: rawText) + } +} diff --git a/TablePro/Core/Services/Query/ExplainPlanTextFlattener.swift b/TablePro/Core/Services/Query/ExplainPlanTextFlattener.swift new file mode 100644 index 000000000..facdf867d --- /dev/null +++ b/TablePro/Core/Services/Query/ExplainPlanTextFlattener.swift @@ -0,0 +1,18 @@ +// +// ExplainPlanTextFlattener.swift +// TablePro +// +// Turns EXPLAIN result rows into the single block of text the plan parsers read. One rule for +// every entry point, so the Explain action and a hand-typed statement produce identical text. +// + +import Foundation +import TableProPluginKit + +enum ExplainPlanTextFlattener { + static func flatten(rows: [[PluginCellValue]]) -> String { + rows + .map { row in row.compactMap { $0.asText }.joined(separator: "\t") } + .joined(separator: "\n") + } +} diff --git a/TablePro/Core/Services/Query/ExplainResultRouter.swift b/TablePro/Core/Services/Query/ExplainResultRouter.swift index 60a071e70..cd1ea2c56 100644 --- a/TablePro/Core/Services/Query/ExplainResultRouter.swift +++ b/TablePro/Core/Services/Query/ExplainResultRouter.swift @@ -2,14 +2,40 @@ // ExplainResultRouter.swift // TablePro // +// Decides whether the result of a hand-typed statement is a query plan rather than a grid. +// import Foundation import TableProPluginKit enum ExplainResultRouter { - static func planText(sql: String, columns: [String], rows: [[PluginCellValue]]) -> String? { - guard QueryClassifier.isExplainStatement(sql), columns.count == 1 else { return nil } - let text = rows.map { $0.first?.asText ?? "" }.joined(separator: "\n") - return text.isEmpty ? nil : text + struct RoutedPlan { + let rawText: String + let plan: QueryPlan? + } + + /// A plan either arrives in one column, or is multi-column output the app can actually read + /// as a tree. Requiring a successful parse for the multi-column case is what lets SQLite's + /// four-column `EXPLAIN QUERY PLAN` reach the viewer while MySQL's tabular `EXPLAIN`, which + /// no parser understands, stays in the results grid where it belongs. + static func route( + sql: String, + columns: [String], + rows: [[PluginCellValue]], + databaseType: DatabaseType, + declaredVariants: [ExplainVariant] + ) -> RoutedPlan? { + guard QueryClassifier.isExplainStatement(sql) else { return nil } + + let text = ExplainPlanTextFlattener.flatten(rows: rows) + guard !text.isEmpty else { return nil } + + let format = ExplainFormatResolver.resolve( + sql: sql, databaseType: databaseType, declaredVariants: declaredVariants + ) + let plan = ExplainPlanParserRegistry.plan(from: text, format: format) + + guard columns.count == 1 || plan != nil else { return nil } + return RoutedPlan(rawText: text, plan: plan) } } diff --git a/TablePro/Core/Services/Query/QueryPlanParser.swift b/TablePro/Core/Services/Query/QueryPlanParser.swift index d2acc3bfb..3d1db9efd 100644 --- a/TablePro/Core/Services/Query/QueryPlanParser.swift +++ b/TablePro/Core/Services/Query/QueryPlanParser.swift @@ -83,14 +83,30 @@ struct PostgreSQLPlanParser: QueryPlanParser { } } -// MARK: - MySQL JSON Parser +// MARK: - MySQL Parsers + +struct MySQLPlanParser: QueryPlanParser { + static let maximumInputBytes = 2_000_000 + + func parse(rawText: String) -> QueryPlan? { + guard rawText.utf8.count <= Self.maximumInputBytes else { + logger.debug("MySQL EXPLAIN plan exceeds parser input limit") + return nil + } + return MySQLJsonPlanParser().parse(rawText: rawText) + ?? MySQLTreePlanParser().parse(rawText: rawText) + } +} /// Parses MySQL and MariaDB `EXPLAIN FORMAT=JSON` output. /// Handles both MySQL's flat structure and MariaDB's nested structure /// (query_block → filesort → temporary_table → nested_loop). -struct MySQLPlanParser: QueryPlanParser { +struct MySQLJsonPlanParser: QueryPlanParser { func parse(rawText: String) -> QueryPlan? { - guard let data = rawText.data(using: .utf8), + let trimmed = rawText.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.first == "{" else { return nil } + + guard let data = trimmed.data(using: .utf8), let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let queryBlock = json["query_block"] as? [String: Any] else { @@ -205,6 +221,254 @@ struct MySQLPlanParser: QueryPlanParser { } } +struct MySQLTreePlanParser: QueryPlanParser { + private struct RawNode { + let depth: Int + let text: String + } + + private struct ParsedMetrics { + var estimatedCost: Double? + var estimatedRows: Int? + var actualStartupTime: Double? + var actualTotalTime: Double? + var actualRows: Int? + var actualLoops: Int? + var wasExecuted = true + } + + private struct ParsedNodeText { + let operation: String + let relation: String? + let properties: [String: String] + let metrics: ParsedMetrics + } + + private static let maximumNodes = 10_000 + private static let maximumDepth = 128 + private static let numberPattern = "[-+]?(?:\\d+(?:\\.\\d*)?|\\.\\d+)(?:[eE][-+]?\\d+)?" + private static let actualMetricsRegex = try? NSRegularExpression( + pattern: "\\s+\\(actual time=(\(numberPattern))\\.\\.(\(numberPattern))" + + "\\s+rows=(\(numberPattern))\\s+loops=(\(numberPattern))\\)\\s*$" + ) + private static let costMetricsRegex = try? NSRegularExpression( + pattern: "\\s+\\(cost=(\(numberPattern))\\s+rows=(\(numberPattern))\\)\\s*$" + ) + private static let neverExecutedRegex = try? NSRegularExpression( + pattern: "\\s+\\(never executed\\)\\s*$", + options: [.caseInsensitive] + ) + + func parse(rawText: String) -> QueryPlan? { + guard let rawNodes = parseRawNodes(rawText), !rawNodes.isEmpty else { return nil } + + let roots = QueryPlanTreeBuilder.forest(from: rawNodes, depth: \.depth) { rawNode, children in + Self.makeNode(rawNode, children: children) + } + guard let rootNode = QueryPlanTreeBuilder.root(from: roots) else { return nil } + + let executionTime = roots.compactMap(Self.totalExecutionTime).max() + var plan = QueryPlan( + rootNode: rootNode, + planningTime: nil, + executionTime: executionTime, + rawText: rawText + ) + plan.computeCostFractions() + return plan + } + + private static func makeNode(_ rawNode: RawNode, children: [QueryPlanNode]) -> QueryPlanNode { + let parsed = parseNodeText(rawNode.text) + return QueryPlanNode( + operation: parsed.operation, + relation: parsed.relation, + schema: nil, + alias: nil, + estimatedStartupCost: nil, + estimatedTotalCost: parsed.metrics.estimatedCost, + estimatedRows: parsed.metrics.estimatedRows, + estimatedWidth: nil, + actualStartupTime: parsed.metrics.actualStartupTime, + actualTotalTime: parsed.metrics.actualTotalTime, + actualRows: parsed.metrics.actualRows, + actualLoops: parsed.metrics.actualLoops, + properties: parsed.properties, + children: children + ) + } + + /// A TREE plan starts a node at every `->` line, so every line in between belongs to the + /// node above it: MySQL wraps the metrics group, and node text keeps any newline the + /// query's own literals contain. + private func parseRawNodes(_ rawText: String) -> [RawNode]? { + var nodes: [RawNode] = [] + var indentationStack: [Int] = [] + var pending: (indent: Int, text: String)? + + func appendPending() -> Bool { + guard let pending else { return true } + while let lastIndent = indentationStack.last, pending.indent <= lastIndent { + indentationStack.removeLast() + } + guard indentationStack.count < Self.maximumDepth, + nodes.count < Self.maximumNodes else { return false } + indentationStack.append(pending.indent) + nodes.append(RawNode(depth: indentationStack.count - 1, text: pending.text)) + return true + } + + for line in rawText.components(separatedBy: .newlines) { + if let nodeLine = Self.nodeLine(from: line) { + guard appendPending() else { return nil } + pending = nodeLine + continue + } + + let continuation = line.trimmingCharacters(in: .whitespacesAndNewlines) + if var current = pending, !continuation.isEmpty { + current.text += " \(continuation)" + pending = current + } + } + + guard appendPending() else { return nil } + return nodes + } + + private static func nodeLine(from line: String) -> (indent: Int, text: String)? { + let leadingWhitespace = line.prefix { $0 == " " || $0 == "\t" } + let remainder = line.dropFirst(leadingWhitespace.count) + guard remainder.hasPrefix("->") else { return nil } + + let text = remainder.dropFirst(2).trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { return nil } + + let indent = leadingWhitespace.reduce(into: 0) { width, character in + width += character == "\t" ? 4 : 1 + } + return (indent, text) + } + + private static func parseNodeText(_ rawText: String) -> ParsedNodeText { + var text = rawText.trimmingCharacters(in: .whitespacesAndNewlines) + var metrics = ParsedMetrics() + + if let match = match(Self.actualMetricsRegex, in: text), match.numberOfRanges == 5 { + metrics.actualStartupTime = nonnegativeDouble(capture(1, from: match, in: text)) + metrics.actualTotalTime = nonnegativeDouble(capture(2, from: match, in: text)) + metrics.actualRows = roundedInt(capture(3, from: match, in: text)) + metrics.actualLoops = wholeInt(capture(4, from: match, in: text)) + text = removing(match, from: text) + } else if let match = match(Self.neverExecutedRegex, in: text) { + metrics.wasExecuted = false + text = removing(match, from: text) + } + + if let match = match(Self.costMetricsRegex, in: text), match.numberOfRanges == 3 { + metrics.estimatedCost = nonnegativeDouble(capture(1, from: match, in: text)) + metrics.estimatedRows = roundedInt(capture(2, from: match, in: text)) + text = removing(match, from: text) + } + + let presentation = accessPresentation(from: text) + var properties = presentation.properties + if !metrics.wasExecuted { + properties["Execution"] = String(localized: "Never executed") + } + return ParsedNodeText( + operation: presentation.operation, + relation: presentation.relation, + properties: properties, + metrics: metrics + ) + } + + private static func accessPresentation( + from text: String + ) -> (operation: String, relation: String?, properties: [String: String]) { + guard let onRange = text.range(of: " on ", options: [.caseInsensitive]), + isAccessOperation(String(text[.. Bool { + let lowercased = operation.lowercased() + return lowercased.contains("scan") || lowercased.contains("lookup") || lowercased.contains("search") + } + + private static func leadingIdentifier(in text: String) -> String? { + let trimmed = text.trimmingCharacters(in: .whitespaces) + guard let first = trimmed.first else { return nil } + + let closingCharacter: Character? + switch first { + case "`": closingCharacter = "`" + case "<": closingCharacter = ">" + default: closingCharacter = nil + } + + if let closingCharacter, + let end = trimmed.dropFirst().firstIndex(of: closingCharacter) { + return String(trimmed[...end]) + } + let identifier = String(trimmed.prefix { !$0.isWhitespace && $0 != "(" }) + return identifier.isEmpty ? nil : identifier + } + + private static func match(_ regex: NSRegularExpression?, in text: String) -> NSTextCheckingResult? { + guard let regex else { return nil } + return regex.firstMatch(in: text, range: NSRange(text.startIndex..., in: text)) + } + + private static func capture(_ index: Int, from match: NSTextCheckingResult, in text: String) -> String? { + guard let range = Range(match.range(at: index), in: text) else { return nil } + return String(text[range]) + } + + private static func removing(_ match: NSTextCheckingResult, from text: String) -> String { + guard let range = Range(match.range, in: text) else { return text } + var result = text + result.removeSubrange(range) + return result.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private static func nonnegativeDouble(_ text: String?) -> Double? { + guard let text, let value = Double(text), value.isFinite, value >= 0 else { return nil } + return value + } + + private static func roundedInt(_ text: String?) -> Int? { + guard let value = nonnegativeDouble(text), value < Double(Int.max) else { return nil } + return Int(value.rounded()) + } + + private static func wholeInt(_ text: String?) -> Int? { + guard let value = nonnegativeDouble(text), value.rounded() == value, + value < Double(Int.max) else { return nil } + return Int(value) + } + + private static func totalExecutionTime(_ node: QueryPlanNode) -> Double? { + guard let time = node.actualTotalTime else { return nil } + let total = time * Double(node.actualLoops ?? 1) + return total.isFinite ? total : nil + } +} + // MARK: - SQLite Parser /// Parses SQLite `EXPLAIN QUERY PLAN` output (id/parent/notused/detail columns). @@ -247,21 +511,8 @@ struct SQLitePlanParser: QueryPlanParser { // Find the minimum parent ID to use as the virtual root parent let minParent = nodes.map(\.parent).min() ?? 0 - let rootChildren = buildChildren(parentId: minParent) - let rootNode: QueryPlanNode - if rootChildren.count == 1 { - rootNode = rootChildren[0] - } else { - rootNode = QueryPlanNode( - operation: "Query Plan", - relation: nil, schema: nil, alias: nil, - estimatedStartupCost: nil, estimatedTotalCost: nil, - estimatedRows: nil, estimatedWidth: nil, - actualStartupTime: nil, actualTotalTime: nil, - actualRows: nil, actualLoops: nil, - properties: [:], - children: rootChildren - ) + guard let rootNode = QueryPlanTreeBuilder.root(from: buildChildren(parentId: minParent)) else { + return nil } return QueryPlan(rootNode: rootNode, planningTime: nil, executionTime: nil, rawText: rawText) @@ -325,21 +576,7 @@ struct IndentedTextPlanParser: QueryPlanParser { } let result = buildNodes(from: 0, parentIndent: -1) - let rootNode: QueryPlanNode - if result.nodes.count == 1 { - rootNode = result.nodes[0] - } else { - rootNode = QueryPlanNode( - operation: "Query Plan", - relation: nil, schema: nil, alias: nil, - estimatedStartupCost: nil, estimatedTotalCost: nil, - estimatedRows: nil, estimatedWidth: nil, - actualStartupTime: nil, actualTotalTime: nil, - actualRows: nil, actualLoops: nil, - properties: [:], - children: result.nodes - ) - } + guard let rootNode = QueryPlanTreeBuilder.root(from: result.nodes) else { return nil } return QueryPlan(rootNode: rootNode, planningTime: nil, executionTime: nil, rawText: rawText) } @@ -389,37 +626,10 @@ struct CockroachDBPlanParser: QueryPlanParser { nodes.append(RawNode(depth: depth, operation: operation, properties: [:])) } - guard !nodes.isEmpty else { return nil } - - var index = 0 - func build(parentDepth: Int) -> [QueryPlanNode] { - var result: [QueryPlanNode] = [] - while index < nodes.count { - let raw = nodes[index] - if raw.depth <= parentDepth { break } - index += 1 - let children = build(parentDepth: raw.depth) - result.append(Self.makeNode(raw, children: children)) - } - return result - } - - let roots = build(parentDepth: -1) - let rootNode: QueryPlanNode - if roots.count == 1 { - rootNode = roots[0] - } else { - rootNode = QueryPlanNode( - operation: "Query Plan", - relation: nil, schema: nil, alias: nil, - estimatedStartupCost: nil, estimatedTotalCost: nil, - estimatedRows: nil, estimatedWidth: nil, - actualStartupTime: nil, actualTotalTime: nil, - actualRows: nil, actualLoops: nil, - properties: [:], - children: roots - ) + let roots = QueryPlanTreeBuilder.forest(from: nodes, depth: \.depth) { raw, children in + Self.makeNode(raw, children: children) } + guard let rootNode = QueryPlanTreeBuilder.root(from: roots) else { return nil } return QueryPlan( rootNode: rootNode, @@ -481,24 +691,3 @@ struct CockroachDBPlanParser: QueryPlanParser { } } } - -// MARK: - Factory - -enum QueryPlanParserFactory { - static func parser(for databaseType: DatabaseType) -> QueryPlanParser? { - switch databaseType { - case .postgresql, .redshift: - return PostgreSQLPlanParser() - case .cockroachdb: - return CockroachDBPlanParser() - case .mysql, .mariadb: - return MySQLPlanParser() - case .sqlite: - return SQLitePlanParser() - case .clickhouse, .duckdb: - return IndentedTextPlanParser() - default: - return nil - } - } -} diff --git a/TablePro/Core/Services/Query/QueryPlanTreeBuilder.swift b/TablePro/Core/Services/Query/QueryPlanTreeBuilder.swift new file mode 100644 index 000000000..99e7abf66 --- /dev/null +++ b/TablePro/Core/Services/Query/QueryPlanTreeBuilder.swift @@ -0,0 +1,54 @@ +// +// QueryPlanTreeBuilder.swift +// TablePro +// +// Shared tree assembly for the text-based EXPLAIN parsers. +// + +import Foundation + +enum QueryPlanTreeBuilder { + /// Builds a forest from a pre-order list where each element carries its own depth. + /// An element belongs to the closest preceding element with a smaller depth. + static func forest( + from elements: [Element], + depth: (Element) -> Int, + makeNode: (Element, [QueryPlanNode]) -> QueryPlanNode + ) -> [QueryPlanNode] { + var index = 0 + + func build(parentDepth: Int) -> [QueryPlanNode] { + var nodes: [QueryPlanNode] = [] + while index < elements.count { + let element = elements[index] + let elementDepth = depth(element) + if elementDepth <= parentDepth { break } + index += 1 + nodes.append(makeNode(element, build(parentDepth: elementDepth))) + } + return nodes + } + + return build(parentDepth: -1) + } + + /// Returns the single root, or wraps several roots in one synthetic node whose cost is the + /// sum of the roots that report one, so cost fractions stay relative to a real total. + static func root(from roots: [QueryPlanNode]) -> QueryPlanNode? { + if roots.count == 1 { return roots[0] } + guard !roots.isEmpty else { return nil } + + let costs = roots.compactMap(\.estimatedTotalCost) + return QueryPlanNode( + operation: "Query Plan", + relation: nil, schema: nil, alias: nil, + estimatedStartupCost: nil, + estimatedTotalCost: costs.isEmpty ? nil : costs.reduce(0, +), + estimatedRows: nil, estimatedWidth: nil, + actualStartupTime: nil, actualTotalTime: nil, + actualRows: nil, actualLoops: nil, + properties: [:], + children: roots + ) + } +} diff --git a/TablePro/Core/Storage/Preferences/PreferenceKeys.swift b/TablePro/Core/Storage/Preferences/PreferenceKeys.swift index fd793587b..f72687792 100644 --- a/TablePro/Core/Storage/Preferences/PreferenceKeys.swift +++ b/TablePro/Core/Storage/Preferences/PreferenceKeys.swift @@ -11,6 +11,7 @@ enum PreferenceKeys { static let selectedSettingsPane = DefaultsKey("com.TablePro.settings.selectedPane") static let rowInspectorJsonFieldHeight = DefaultsKey("com.TablePro.rightSidebar.jsonFieldHeight") static let workspaceRailOrder = DefaultsKey<[WorkspaceID]>("com.TablePro.workspaceRail.order") + static let queryPlanRawFontSize = DefaultsKey("com.TablePro.queryPlan.rawFontSize") static let registeredKeyNames: [String] = [ linkedFolders.name, @@ -18,6 +19,7 @@ enum PreferenceKeys { selectedSettingsPane.name, rowInspectorJsonFieldHeight.name, workspaceRailOrder.name, + queryPlanRawFontSize.name, ] static func columnDisplayFormats(_ scope: TableScope) -> DefaultsKey<[String: ValueDisplayFormat]> { diff --git a/TablePro/Models/ClickHouse/ClickHouseExplainVariant.swift b/TablePro/Models/ClickHouse/ClickHouseExplainVariant.swift deleted file mode 100644 index 411fa8b0c..000000000 --- a/TablePro/Models/ClickHouse/ClickHouseExplainVariant.swift +++ /dev/null @@ -1,30 +0,0 @@ -// -// ClickHouseExplainVariant.swift -// TablePro -// -// EXPLAIN variants supported by ClickHouse. -// - -import Foundation - -/// ClickHouse-specific EXPLAIN variants -enum ClickHouseExplainVariant: String, CaseIterable, Identifiable { - case plan = "Plan" - case pipeline = "Pipeline" - case ast = "AST" - case syntax = "Syntax" - case estimate = "Estimate" - - var id: String { rawValue } - - /// SQL keyword to prepend to the query - var sqlKeyword: String { - switch self { - case .plan: return "EXPLAIN" - case .pipeline: return "EXPLAIN PIPELINE" - case .ast: return "EXPLAIN AST" - case .syntax: return "EXPLAIN SYNTAX" - case .estimate: return "EXPLAIN ESTIMATE" - } - } -} diff --git a/TablePro/Models/Diagram/DiagramZoom.swift b/TablePro/Models/Diagram/DiagramZoom.swift new file mode 100644 index 000000000..8fd1d2d1c --- /dev/null +++ b/TablePro/Models/Diagram/DiagramZoom.swift @@ -0,0 +1,29 @@ +// +// DiagramZoom.swift +// TablePro +// +// Zoom bounds shared by the ER diagram and the EXPLAIN plan diagram. +// + +import CoreGraphics + +enum DiagramZoom { + static let minimum: CGFloat = 0.25 + static let maximum: CGFloat = 3.0 + static let step: CGFloat = 0.25 + + static func clamped(_ value: CGFloat) -> CGFloat { + if value.isNaN { return 1.0 } + if value == .infinity { return maximum } + if value == -.infinity { return minimum } + return min(maximum, max(minimum, value)) + } + + static func scaled(from startingMagnification: CGFloat, by gestureMagnification: CGFloat) -> CGFloat { + let startingMagnification = clamped(startingMagnification) + guard gestureMagnification.isFinite, gestureMagnification > 0 else { + return startingMagnification + } + return clamped(startingMagnification * gestureMagnification) + } +} diff --git a/TablePro/Models/ERDiagram/ERDiagramScrollTranslator.swift b/TablePro/Models/ERDiagram/ERDiagramScrollTranslator.swift deleted file mode 100644 index ce02113f1..000000000 --- a/TablePro/Models/ERDiagram/ERDiagramScrollTranslator.swift +++ /dev/null @@ -1,26 +0,0 @@ -import CoreGraphics - -enum ERDiagramScrollAction: Equatable { - case pan(CGPoint) - case zoom(CGFloat) -} - -enum ERDiagramScrollTranslator { - static func action( - scrollingDeltaX: CGFloat, - scrollingDeltaY: CGFloat, - hasPreciseScrollingDeltas: Bool, - isZoomModifierActive: Bool, - currentOffset: CGPoint, - currentMagnification: CGFloat - ) -> ERDiagramScrollAction { - if isZoomModifierActive { - return .zoom(currentMagnification + scrollingDeltaY * 0.01) - } - let multiplier: CGFloat = hasPreciseScrollingDeltas ? 1.0 : 10.0 - return .pan(CGPoint( - x: currentOffset.x + scrollingDeltaX * multiplier, - y: currentOffset.y + scrollingDeltaY * multiplier - )) - } -} diff --git a/TablePro/Models/Query/ExplainRequest.swift b/TablePro/Models/Query/ExplainRequest.swift new file mode 100644 index 000000000..9c094d8e2 --- /dev/null +++ b/TablePro/Models/Query/ExplainRequest.swift @@ -0,0 +1,44 @@ +// +// ExplainRequest.swift +// TablePro +// +// The SQL an EXPLAIN run sends and the format its output will come back in. +// + +import Foundation +import TableProPluginKit + +struct ExplainRequest: Equatable { + let sql: String + let format: ExplainPlanFormat + + /// A driver that declares no variants and builds its own statement may return anything, + /// including a multi-column document. Those results go through the ordinary query pipeline + /// so they keep their grid rather than being forced into a plan pane. + let isDriverBuilt: Bool + + /// Picks the variant to run: the one the user chose, otherwise the driver's first declared + /// one. Returns nil when the driver declares none, which is the caller's cue to fall back to + /// `buildExplainQuery`. + static func make( + variant: ExplainVariant?, + declaredVariants: [ExplainVariant], + databaseType: DatabaseType, + statement: String + ) -> ExplainRequest? { + guard let resolved = variant ?? declaredVariants.first else { return nil } + return ExplainRequest( + sql: "\(resolved.sqlPrefix) \(statement)", + format: ExplainFormatResolver.resolve(declared: resolved.format, databaseType: databaseType), + isDriverBuilt: false + ) + } + + static func driverBuilt(sql: String, databaseType: DatabaseType) -> ExplainRequest { + ExplainRequest( + sql: sql, + format: ExplainFormatResolver.resolve(declared: .plainText, databaseType: databaseType), + isDriverBuilt: true + ) + } +} diff --git a/TablePro/Models/Query/ExplainResultSetFactory.swift b/TablePro/Models/Query/ExplainResultSetFactory.swift new file mode 100644 index 000000000..019c6f987 --- /dev/null +++ b/TablePro/Models/Query/ExplainResultSetFactory.swift @@ -0,0 +1,26 @@ +// +// ExplainResultSetFactory.swift +// TablePro +// +// Builds the result set an EXPLAIN produces. Both entry points, the Explain action and a +// hand-typed statement, go through here so a plan looks the same however it was asked for. +// + +import Foundation + +@MainActor +enum ExplainResultSetFactory { + static func make( + rawText: String, + plan: QueryPlan?, + sql: String, + executionTime: TimeInterval? + ) -> ResultSet { + let resultSet = ResultSet(label: String(localized: "Plan")) + resultSet.explainRawText = rawText + resultSet.queryPlan = plan + resultSet.baseQuery = sql + resultSet.executionTime = executionTime + return resultSet + } +} diff --git a/TablePro/Models/Query/QueryPlan.swift b/TablePro/Models/Query/QueryPlan.swift index 250b44b47..7562f3799 100644 --- a/TablePro/Models/Query/QueryPlan.swift +++ b/TablePro/Models/Query/QueryPlan.swift @@ -33,6 +33,14 @@ struct QueryPlanNode: Identifiable { let childCost = children.reduce(0.0) { $0 + ($1.estimatedTotalCost ?? 0) } return max(0, (estimatedTotalCost ?? 0) - childCost) } + + /// `startup..total` when the plan reports both, `total` alone when it reports only the total. + func costRangeText(fractionDigits: Int) -> String? { + guard let total = estimatedTotalCost else { return nil } + let number = "%.\(fractionDigits)f" + guard let startup = estimatedStartupCost else { return String(format: number, total) } + return String(format: "\(number)..\(number)", startup, total) + } } /// A parsed EXPLAIN query plan. @@ -42,10 +50,10 @@ struct QueryPlan { let executionTime: Double? let rawText: String - /// Compute cost fractions relative to root total cost. + /// Compute cost fractions relative to root total cost. A plan whose root reports no cost + /// keeps every fraction at zero rather than dividing by a made-up total. mutating func computeCostFractions() { - let totalCost = rootNode.estimatedTotalCost ?? 1 - guard totalCost > 0 else { return } + guard let totalCost = rootNode.estimatedTotalCost, totalCost > 0 else { return } assignFractions(node: &rootNode, totalCost: totalCost) } diff --git a/TablePro/Models/Query/QueryPlanLabels.swift b/TablePro/Models/Query/QueryPlanLabels.swift new file mode 100644 index 000000000..45a35c331 --- /dev/null +++ b/TablePro/Models/Query/QueryPlanLabels.swift @@ -0,0 +1,38 @@ +// +// QueryPlanLabels.swift +// TablePro +// +// Every label the plan views draw. They reach the views as String parameters rather than as +// SwiftUI view literals, so they need String(localized:) explicitly to be translated. +// + +import Foundation + +enum QueryPlanLabels { + static var table: String { String(localized: "Table") } + static var cost: String { String(localized: "Cost") } + static var rows: String { String(localized: "Rows") } + static var width: String { String(localized: "Width") } + static var actual: String { String(localized: "Actual") } + static var actualTime: String { String(localized: "Actual Time") } + static var actualRows: String { String(localized: "Actual Rows") } + static var loops: String { String(localized: "Loops") } + static var details: String { String(localized: "Details") } + static var operation: String { String(localized: "Operation") } + + /// Boolean flags and zero-value noise a driver reports that add nothing to the display. + static let hiddenPropertyKeys: Set = [ + "Parallel Aware", "Async Capable", "Disabled", "Inner Unique", + ] + + static func visibleProperties(of node: QueryPlanNode) -> [(key: String, value: String)] { + node.properties + .filter { !hiddenPropertyKeys.contains($0.key) } + .filter { $0.value != "false" && $0.value != "0" } + .sorted { $0.key < $1.key } + } + + static func milliseconds(_ value: Double) -> String { + String(format: String(localized: "%.3fms"), value) + } +} diff --git a/TablePro/Models/Query/QueryPlanNodeSummary.swift b/TablePro/Models/Query/QueryPlanNodeSummary.swift new file mode 100644 index 000000000..f0296a365 --- /dev/null +++ b/TablePro/Models/Query/QueryPlanNodeSummary.swift @@ -0,0 +1,52 @@ +// +// QueryPlanNodeSummary.swift +// TablePro +// +// Plain-text renderings of a plan node, for copying and for VoiceOver. +// + +import Foundation + +enum QueryPlanNodeSummary { + /// The whole node as `Label: value` lines, for Copy Node Details. + static func text(for node: QueryPlanNode) -> String { + var lines = [node.operation] + + if let relation = node.relation { lines.append("\(QueryPlanLabels.table): \(relation)") } + if let cost = node.costRangeText(fractionDigits: 2) { lines.append("\(QueryPlanLabels.cost): \(cost)") } + if let rows = node.estimatedRows { lines.append("\(QueryPlanLabels.rows): \(rows)") } + if let width = node.estimatedWidth, width > 0 { lines.append("\(QueryPlanLabels.width): \(width)") } + if let time = node.actualTotalTime { + lines.append("\(QueryPlanLabels.actualTime): \(QueryPlanLabels.milliseconds(time))") + } + if let rows = node.actualRows { lines.append("\(QueryPlanLabels.actualRows): \(rows)") } + if let loops = node.actualLoops, loops > 1 { lines.append("\(QueryPlanLabels.loops): \(loops)") } + + for property in QueryPlanLabels.visibleProperties(of: node) { + lines.append("\(property.key): \(property.value)") + } + + return lines.joined(separator: "\n") + } + + /// One spoken sentence: what the node does, on what, how expensive it is. + static func accessibilityLabel(for node: QueryPlanNode) -> String { + var parts = [node.operation] + + if let relation = node.relation { + parts.append(String(format: String(localized: "on %@"), relation)) + } + parts.append(node.severity.accessibilityLabel) + if let cost = node.costRangeText(fractionDigits: 2) { + parts.append("\(QueryPlanLabels.cost) \(cost)") + } + if let rows = node.estimatedRows { + parts.append("\(rows) \(QueryPlanLabels.rows)") + } + if let time = node.actualTotalTime { + parts.append("\(QueryPlanLabels.actualTime) \(QueryPlanLabels.milliseconds(time))") + } + + return parts.joined(separator: ", ") + } +} diff --git a/TablePro/Models/Query/QueryPlanSeverity.swift b/TablePro/Models/Query/QueryPlanSeverity.swift new file mode 100644 index 000000000..28fc389ec --- /dev/null +++ b/TablePro/Models/Query/QueryPlanSeverity.swift @@ -0,0 +1,30 @@ +// +// QueryPlanSeverity.swift +// TablePro +// +// How expensive a plan node is relative to the whole plan. Kept free of SwiftUI so the +// thresholds can be tested directly and both plan views classify identically. +// + +import Foundation + +enum QueryPlanSeverity: CaseIterable { + case low + case moderate + case high + case critical + + static func forCostFraction(_ fraction: Double) -> QueryPlanSeverity { + guard fraction.isFinite else { return .low } + if fraction > 0.5 { return .critical } + if fraction > 0.2 { return .high } + if fraction > 0.05 { return .moderate } + return .low + } +} + +extension QueryPlanNode { + var severity: QueryPlanSeverity { + QueryPlanSeverity.forCostFraction(costFraction) + } +} diff --git a/TablePro/Models/Query/QueryTabState.swift b/TablePro/Models/Query/QueryTabState.swift index 5c5d31e37..dde34c15b 100644 --- a/TablePro/Models/Query/QueryTabState.swift +++ b/TablePro/Models/Query/QueryTabState.swift @@ -461,9 +461,6 @@ struct TabQueryContent: Equatable { struct TabDisplayState: Equatable { var resultsViewMode: ResultsViewMode = .data var erDiagramSchemaKey: String? - var explainText: String? - var explainExecutionTime: TimeInterval? - var explainPlan: QueryPlan? var isResultsCollapsed: Bool = false var resultSets: [ResultSet] = [] var activeResultSetId: UUID? @@ -487,6 +484,12 @@ struct TabDisplayState: Equatable { activeResultSetId = resultSets.last?.id } + @MainActor + var activeExplainResult: ResultSet? { + guard let activeResultSet, activeResultSet.isExplainResult else { return nil } + return activeResultSet + } + @MainActor mutating func togglePin(resultSetId: UUID) { guard let target = resultSets.first(where: { $0.id == resultSetId }) else { return } diff --git a/TablePro/Models/Query/ResultSet.swift b/TablePro/Models/Query/ResultSet.swift index 67489f330..b39f40fad 100644 --- a/TablePro/Models/Query/ResultSet.swift +++ b/TablePro/Models/Query/ResultSet.swift @@ -30,6 +30,13 @@ final class ResultSet: Identifiable { var pagination = PaginationState() var columnLayout = ColumnLayoutState() + /// An EXPLAIN result is a result set like any other, so it rides the same tab strip, pinning + /// and history. It carries a plan instead of rows. + var queryPlan: QueryPlan? + var explainRawText: String? + + var isExplainResult: Bool { explainRawText != nil } + var resultColumns: [String] { tableRows.columns } init(id: UUID = UUID(), label: String, tableRows: TableRows = TableRows()) { diff --git a/TablePro/Models/Query/ResultTabBarPolicy.swift b/TablePro/Models/Query/ResultTabBarPolicy.swift index 7eb6673dd..6dfbffe53 100644 --- a/TablePro/Models/Query/ResultTabBarPolicy.swift +++ b/TablePro/Models/Query/ResultTabBarPolicy.swift @@ -11,7 +11,6 @@ import Foundation enum ResultTabBarPolicy { static func showsTabBar(tabType: TabType, display: TabDisplayState) -> Bool { guard tabType == .query else { return false } - guard display.explainText == nil else { return false } guard display.resultsViewMode != .structure else { return false } return !display.resultSets.isEmpty } diff --git a/TablePro/ViewModels/ERDiagramViewModel.swift b/TablePro/ViewModels/ERDiagramViewModel.swift index 5fca5e825..7ce3db503 100644 --- a/TablePro/ViewModels/ERDiagramViewModel.swift +++ b/TablePro/ViewModels/ERDiagramViewModel.swift @@ -58,7 +58,6 @@ final class ERDiagramViewModel { var loadState: LoadState = .loading var needsInitialFit = true var graph: ERDiagramGraph = .empty - var magnification: CGFloat = 1.0 var isCompactMode = false { didSet { rebuildVisibleGraph() } } @@ -75,15 +74,15 @@ final class ERDiagramViewModel { // MARK: - Canvas Viewport - var canvasOffset: CGPoint = .zero - var viewportSize: CGSize = .zero + /// AppKit owns pan and zoom, so every coordinate the view hands over is already in document + /// space. The viewport is only needed to nudge the scroll position while auto-panning. + @ObservationIgnored weak var viewport: DiagramViewportController? // MARK: - Drag State private(set) var isDragging = false private(set) var draggingNodeId: UUID? @ObservationIgnored private var dragNodeStart: CGPoint? - @ObservationIgnored private var panStart: CGPoint? @ObservationIgnored private var lastDragTranslation: CGSize = .zero // MARK: - Auto-Pan @@ -223,12 +222,20 @@ final class ERDiagramViewModel { func setPositionOverride(nodeId: UUID, position: CGPoint) { positionOverrides[nodeId] = position let height = ERDiagramLayout.estimateHeight(columnCount: columnCountByNodeId[nodeId] ?? 1) - cachedNodeRects[nodeId] = CGRect( + let rect = CGRect( x: position.x - ERDiagramLayout.nodeWidth / 2, y: position.y - height / 2, width: ERDiagramLayout.nodeWidth, height: height ) + cachedNodeRects[nodeId] = rect + + // The scroll view's document is sized from this, so a node dragged past the load-time + // bounds has to grow it or the node ends up somewhere the canvas cannot scroll to. + cachedCanvasSize = CGSize( + width: max(cachedCanvasSize.width, rect.maxX + Self.canvasPadding), + height: max(cachedCanvasSize.height, rect.maxY + Self.canvasPadding) + ) } func persistPositions() { @@ -329,6 +336,7 @@ final class ERDiagramViewModel { // MARK: - Canvas Size private(set) var cachedCanvasSize = CGSize(width: 800, height: 600) + private static let canvasPadding: CGFloat = 80 // MARK: - Node Rect (for edge rendering) @@ -370,7 +378,9 @@ final class ERDiagramViewModel { csMaxX = max(csMaxX, rect.maxX) csMaxY = max(csMaxY, rect.maxY) } - cachedCanvasSize = CGSize(width: csMaxX + 80, height: csMaxY + 80) + cachedCanvasSize = CGSize( + width: csMaxX + Self.canvasPadding, height: csMaxY + Self.canvasPadding + ) } } @@ -378,42 +388,22 @@ final class ERDiagramViewModel { func beginDrag(at startLocation: CGPoint) { isDragging = true - let canvasPoint = CGPoint( - x: (startLocation.x - canvasOffset.x) / magnification, - y: (startLocation.y - canvasOffset.y) / magnification - ) - var hitNodeId: UUID? - for (id, rect) in cachedNodeRects where rect.contains(canvasPoint) { - hitNodeId = id - break - } - draggingNodeId = hitNodeId - if let nodeId = hitNodeId { - dragNodeStart = position(for: nodeId) - } else { - panStart = canvasOffset - } + draggingNodeId = cachedNodeRects.first { $0.value.contains(startLocation) }?.key + dragNodeStart = draggingNodeId.map { position(for: $0) } } + /// The translation arrives in document units and already carries any scrolling that happened + /// since the drag began, so the accumulator only has to cover the ticks between two events. func updateDrag(translation: CGSize, currentPoint: CGPoint) { lastDragTranslation = translation + guard let nodeId = draggingNodeId, let nodeStart = dragNodeStart else { return } - if let nodeId = draggingNodeId, let nodeStart = dragNodeStart { - let totalDelta = CGSize( - width: (translation.width + autoPanAccum.x) / magnification, - height: (translation.height + autoPanAccum.y) / magnification - ) - setPositionOverride( - nodeId: nodeId, - position: CGPoint(x: nodeStart.x + totalDelta.width, y: nodeStart.y + totalDelta.height) - ) - updateAutoPanVelocity(for: currentPoint) - } else if let start = panStart { - canvasOffset = CGPoint( - x: start.x + translation.width, - y: start.y + translation.height - ) - } + autoPanAccum = .zero + setPositionOverride( + nodeId: nodeId, + position: CGPoint(x: nodeStart.x + translation.width, y: nodeStart.y + translation.height) + ) + updateAutoPanVelocity(for: currentPoint) } func endDrag() { @@ -423,66 +413,72 @@ final class ERDiagramViewModel { isDragging = false draggingNodeId = nil dragNodeStart = nil - panStart = nil lastDragTranslation = .zero stopAutoPan() } + /// The edge band and the pan speed are tuned in screen points, so both are divided by the + /// magnification to reach the document units the viewport scrolls in. private func updateAutoPanVelocity(for point: CGPoint) { - if NSWorkspace.shared.accessibilityDisplayShouldReduceMotion { + guard !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion, let viewport else { + stopAutoPan() + return + } + + let visible = viewport.visibleDocumentRect + guard visible.width > 0, visible.height > 0 else { stopAutoPan() return } - let t = Self.edgeThreshold - let s = Self.maxPanSpeed - var v = CGPoint.zero - - if point.x > viewportSize.width - t { - v.x = -s * min(1, max(0, 1 - (viewportSize.width - point.x) / t)) - } else if point.x < t { - v.x = s * min(1, max(0, 1 - point.x / t)) + + let magnification = max(viewport.magnification, 0.01) + let threshold = Self.edgeThreshold / magnification + let speed = Self.maxPanSpeed / magnification + var velocity = CGPoint.zero + + if point.x > visible.maxX - threshold { + velocity.x = -speed * min(1, max(0, 1 - (visible.maxX - point.x) / threshold)) + } else if point.x < visible.minX + threshold { + velocity.x = speed * min(1, max(0, 1 - (point.x - visible.minX) / threshold)) } - if point.y > viewportSize.height - t { - v.y = -s * min(1, max(0, 1 - (viewportSize.height - point.y) / t)) - } else if point.y < t { - v.y = s * min(1, max(0, 1 - point.y / t)) + if point.y > visible.maxY - threshold { + velocity.y = -speed * min(1, max(0, 1 - (visible.maxY - point.y) / threshold)) + } else if point.y < visible.minY + threshold { + velocity.y = speed * min(1, max(0, 1 - (point.y - visible.minY) / threshold)) } - autoPanVelocity = v - if v != .zero && autoPanTask == nil { + autoPanVelocity = velocity + if velocity != .zero && autoPanTask == nil { autoPanTask = Task { [weak self] in while !Task.isCancelled { self?.autoPanTick() try? await Task.sleep(for: .milliseconds(16)) } } - } else if v == .zero && autoPanTask != nil { + } else if velocity == .zero && autoPanTask != nil { autoPanTask?.cancel() autoPanTask = nil } } private func autoPanTick() { - guard autoPanVelocity != .zero, draggingNodeId != nil else { + guard autoPanVelocity != .zero, let nodeId = draggingNodeId, let nodeStart = dragNodeStart else { stopAutoPan() return } - canvasOffset.x += autoPanVelocity.x - canvasOffset.y += autoPanVelocity.y - autoPanAccum.x -= autoPanVelocity.x - autoPanAccum.y -= autoPanVelocity.y + let delta = CGSize(width: -autoPanVelocity.x, height: -autoPanVelocity.y) + viewport?.scrollBy(delta) + autoPanAccum.x += delta.width + autoPanAccum.y += delta.height - if let nodeId = draggingNodeId, let nodeStart = dragNodeStart { - let totalDelta = CGSize( - width: (lastDragTranslation.width + autoPanAccum.x) / magnification, - height: (lastDragTranslation.height + autoPanAccum.y) / magnification - ) - setPositionOverride( - nodeId: nodeId, - position: CGPoint(x: nodeStart.x + totalDelta.width, y: nodeStart.y + totalDelta.height) + setPositionOverride( + nodeId: nodeId, + position: CGPoint( + x: nodeStart.x + lastDragTranslation.width + autoPanAccum.x, + y: nodeStart.y + lastDragTranslation.height + autoPanAccum.y ) - } + ) } private func stopAutoPan() { @@ -492,41 +488,6 @@ final class ERDiagramViewModel { autoPanAccum = .zero } - // MARK: - Zoom - - func zoom(to newMag: CGFloat, anchor: CGPoint? = nil) { - let clamped = max(0.25, min(3.0, newMag)) - let center = anchor ?? CGPoint(x: viewportSize.width / 2, y: viewportSize.height / 2) - let canvasPoint = CGPoint( - x: (center.x - canvasOffset.x) / magnification, - y: (center.y - canvasOffset.y) / magnification - ) - withMotion(.easeOut(duration: 0.2)) { - canvasOffset = CGPoint( - x: center.x - canvasPoint.x * clamped, - y: center.y - canvasPoint.y * clamped - ) - magnification = clamped - } - } - - func fitToWindow() { - guard !graph.nodes.isEmpty, viewportSize.width > 0, viewportSize.height > 0 else { return } - let diagramSize = cachedCanvasSize - let padding: CGFloat = 40 - let scaleX = (viewportSize.width - padding * 2) / diagramSize.width - let scaleY = (viewportSize.height - padding * 2) / diagramSize.height - let fitScale = max(0.25, min(1.0, min(scaleX, scaleY))) - - withMotion(.easeOut(duration: 0.3)) { - magnification = fitScale - canvasOffset = CGPoint( - x: (viewportSize.width - diagramSize.width * fitScale) / 2, - y: (viewportSize.height - diagramSize.height * fitScale) / 2 - ) - } - } - // MARK: - Private private func loadPersistedPositions() { diff --git a/TablePro/Views/Components/DiagramImageExporter.swift b/TablePro/Views/Components/DiagramImageExporter.swift new file mode 100644 index 000000000..e8725e939 --- /dev/null +++ b/TablePro/Views/Components/DiagramImageExporter.swift @@ -0,0 +1,86 @@ +// +// DiagramImageExporter.swift +// TablePro +// +// Renders a diagram to a PNG at its natural scale, for the standard Copy command and for +// Save. Shared by the ER diagram and the query plan diagram so both export identically. +// + +import AppKit +import os +import SwiftUI +import UniformTypeIdentifiers + +enum DiagramImageExporter { + private static let logger = Logger(subsystem: "com.TablePro", category: "DiagramImageExporter") + private static let renderScale: CGFloat = 2.0 + + @MainActor + static func image(of view: some View) -> NSImage? { + let renderer = ImageRenderer(content: view) + renderer.scale = renderScale + return renderer.nsImage + } + + /// Feeds the standard Edit > Copy command, so a diagram follows whatever shortcut the user + /// has bound instead of a hardcoded key handler. + @MainActor + static func copyItemProviders(of view: some View) -> [NSItemProvider] { + guard let image = image(of: view) else { return [] } + return [NSItemProvider(object: image)] + } + + @MainActor + static func export(_ view: some View, defaultFileName: String, title: String) { + guard let image = image(of: view) else { + logger.error("Failed to render diagram to image") + presentFailure() + return + } + + let panel = NSSavePanel() + panel.allowedContentTypes = [.png] + panel.nameFieldStringValue = defaultFileName + panel.title = title + panel.message = String(localized: "Choose a location to save the diagram as PNG.") + + guard let window = AlertHelper.resolveWindow(nil) else { return } + panel.beginSheetModal(for: window) { response in + guard response == .OK, let url = panel.url else { return } + guard let tiffData = image.tiffRepresentation, + let bitmap = NSBitmapImageRep(data: tiffData), + let pngData = bitmap.representation(using: .png, properties: [:]) + else { + AlertHelper.showErrorSheet( + title: String(localized: "Could not export the diagram"), + message: String(localized: "The diagram could not be converted to a PNG image."), + window: window + ) + return + } + do { + try pngData.write(to: url) + } catch { + logger.error("Failed to write PNG: \(error.localizedDescription)") + AlertHelper.showErrorSheet( + title: String(localized: "Could not export the diagram"), + message: error.localizedDescription, + window: window + ) + } + } + } + + @MainActor + private static func presentFailure() { + let alert = NSAlert() + alert.messageText = String(localized: "Export Failed") + alert.informativeText = String(localized: "Failed to render the diagram image.") + alert.alertStyle = .warning + if let window = AlertHelper.resolveWindow(nil) { + alert.beginSheetModal(for: window) + } else { + alert.runModal() + } + } +} diff --git a/TablePro/Views/Components/DiagramZoomToolbar.swift b/TablePro/Views/Components/DiagramZoomToolbar.swift new file mode 100644 index 000000000..50fd31869 --- /dev/null +++ b/TablePro/Views/Components/DiagramZoomToolbar.swift @@ -0,0 +1,57 @@ +// +// DiagramZoomToolbar.swift +// TablePro +// +// The floating zoom cluster both diagrams share. Extra controls are supplied per diagram. +// + +import SwiftUI + +struct DiagramZoomToolbar: View { + let viewport: DiagramViewportController + @ViewBuilder let extras: () -> Extras + + init(viewport: DiagramViewportController, @ViewBuilder extras: @escaping () -> Extras = { EmptyView() }) { + self.viewport = viewport + self.extras = extras + } + + var body: some View { + HStack(spacing: 8) { + Button(action: viewport.zoomOut) { + Image(systemName: "minus.magnifyingglass") + } + .accessibilityLabel(String(localized: "Zoom Out")) + .help(String(localized: "Zoom Out")) + + Button(action: viewport.resetZoom) { + Text(verbatim: "\(Int((viewport.magnification * 100).rounded()))%") + .font(.system(.caption, design: .monospaced)) + .frame(width: 40) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .accessibilityLabel(String(localized: "Reset Zoom")) + .help(String(localized: "Reset Zoom")) + + Button(action: viewport.zoomIn) { + Image(systemName: "plus.magnifyingglass") + } + .accessibilityLabel(String(localized: "Zoom In")) + .help(String(localized: "Zoom In")) + + Button(action: viewport.fitToWindow) { + Image(systemName: "arrow.up.left.and.arrow.down.right") + } + .accessibilityLabel(String(localized: "Fit to Window")) + .help(String(localized: "Fit to Window")) + + extras() + } + .buttonStyle(.borderless) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .themeMaterial(.toolbar, .thinMaterial, in: Capsule()) + .overlay(Capsule().strokeBorder(.quaternary, lineWidth: 0.5)) + } +} diff --git a/TablePro/Views/Components/MagnifiableCanvasView.swift b/TablePro/Views/Components/MagnifiableCanvasView.swift new file mode 100644 index 000000000..d04962efe --- /dev/null +++ b/TablePro/Views/Components/MagnifiableCanvasView.swift @@ -0,0 +1,170 @@ +// +// MagnifiableCanvasView.swift +// TablePro +// +// A diagram viewport backed by NSScrollView's own magnification. Pinch anchored at the +// pointer, two-finger double tap to smart magnify, Cmd+scroll to zoom, real scrollers that +// follow the system setting and elastic scrolling all come from AppKit rather than being +// rebuilt on top of a SwiftUI ScrollView. +// + +import AppKit +import SwiftUI + +@MainActor +@Observable +final class DiagramViewportController { + private(set) var magnification: CGFloat = 1.0 + + @ObservationIgnored private weak var scrollView: NSScrollView? + @ObservationIgnored private var magnificationObservation: NSKeyValueObservation? + + var visibleDocumentRect: CGRect { + scrollView?.documentVisibleRect ?? .zero + } + + func zoomIn() { + apply(magnification + DiagramZoom.step) + } + + func zoomOut() { + apply(magnification - DiagramZoom.step) + } + + func resetZoom() { + apply(1.0) + } + + /// Fits the whole diagram, but never zooms past 100%: a two-node plan blown up to fill the + /// window reads worse than the same plan at its natural size. + func fitToWindow() { + guard let scrollView, let documentView = scrollView.documentView else { return } + let content = documentView.bounds.size + let visible = scrollView.contentSize + guard content.width > 0, content.height > 0, visible.width > 0, visible.height > 0 else { return } + + apply(min(1.0, min(visible.width / content.width, visible.height / content.height))) + scrollView.contentView.scroll(to: .zero) + scrollView.reflectScrolledClipView(scrollView.contentView) + } + + /// Clamped through the clip view's own rule, so a fast pan or a node dragged against the edge + /// cannot push the document out of view and have AppKit snap it back on the next tile. + func scrollBy(_ delta: CGSize) { + guard let scrollView else { return } + let clipView = scrollView.contentView + let proposed = CGRect( + origin: CGPoint( + x: clipView.bounds.origin.x + delta.width, + y: clipView.bounds.origin.y + delta.height + ), + size: clipView.bounds.size + ) + clipView.scroll(to: clipView.constrainBoundsRect(proposed).origin) + scrollView.reflectScrolledClipView(clipView) + } + + func attach(to scrollView: NSScrollView) { + self.scrollView = scrollView + magnification = scrollView.magnification + magnificationObservation = scrollView.observe(\.magnification, options: [.new]) { [weak self] _, change in + guard let value = change.newValue else { return } + MainActor.assumeIsolated { + self?.magnification = value + } + } + } + + func detach() { + magnificationObservation?.invalidate() + magnificationObservation = nil + scrollView = nil + } + + private func apply(_ value: CGFloat) { + let clamped = DiagramZoom.clamped(value) + guard let scrollView else { + magnification = clamped + return + } + + let centre = CGPoint(x: visibleDocumentRect.midX, y: visibleDocumentRect.midY) + NSAnimationContext.runAnimationGroup { context in + context.duration = MotionAccessibility.systemReduceMotion ? 0 : 0.2 + context.allowsImplicitAnimation = true + scrollView.setMagnification(clamped, centeredAt: centre) + } + magnification = scrollView.magnification + } +} + +struct MagnifiableCanvasView: NSViewRepresentable { + let viewport: DiagramViewportController + let contentSize: CGSize + var accessibilityIdentifier: String? + @ViewBuilder let content: () -> Content + + func makeCoordinator() -> Coordinator { Coordinator() } + + func makeNSView(context: Context) -> NSScrollView { + let scrollView = NSScrollView() + scrollView.allowsMagnification = true + scrollView.minMagnification = DiagramZoom.minimum + scrollView.maxMagnification = DiagramZoom.maximum + scrollView.hasHorizontalScroller = true + scrollView.hasVerticalScroller = true + scrollView.autohidesScrollers = true + scrollView.drawsBackground = false + if let accessibilityIdentifier { + scrollView.setAccessibilityIdentifier(accessibilityIdentifier) + } + + let hostingView = NSHostingView(rootView: content()) + hostingView.translatesAutoresizingMaskIntoConstraints = true + hostingView.frame = CGRect(origin: .zero, size: resolvedContentSize) + scrollView.documentView = hostingView + + context.coordinator.hostingView = hostingView + context.coordinator.viewport = viewport + viewport.attach(to: scrollView) + return scrollView + } + + func updateNSView(_ scrollView: NSScrollView, context: Context) { + guard let hostingView = context.coordinator.hostingView else { return } + hostingView.rootView = content() + + let size = resolvedContentSize + guard hostingView.frame.size != size else { return } + hostingView.frame = CGRect(origin: .zero, size: size) + } + + static func dismantleNSView(_ scrollView: NSScrollView, coordinator: Coordinator) { + MainActor.assumeIsolated { + coordinator.viewport?.detach() + coordinator.viewport = nil + coordinator.hostingView = nil + } + } + + /// Returning the proposal keeps the document's size out of SwiftUI's layout, so a wide + /// diagram never becomes a minimum width that pins the window's split dividers. + func sizeThatFits(_ proposal: ProposedViewSize, nsView: NSScrollView, context: Context) -> CGSize? { + let resolved = proposal.replacingUnspecifiedDimensions(by: CGSize(width: 400, height: 300)) + guard resolved.width.isFinite, resolved.height.isFinite else { return nil } + return resolved + } + + private var resolvedContentSize: CGSize { + CGSize( + width: max(1, contentSize.width.isFinite ? contentSize.width : 1), + height: max(1, contentSize.height.isFinite ? contentSize.height : 1) + ) + } + + @MainActor + final class Coordinator { + var hostingView: NSHostingView? + var viewport: DiagramViewportController? + } +} diff --git a/TablePro/Views/ERDiagram/ERDiagramCanvasContainer.swift b/TablePro/Views/ERDiagram/ERDiagramCanvasContainer.swift deleted file mode 100644 index bd47c6cdb..000000000 --- a/TablePro/Views/ERDiagram/ERDiagramCanvasContainer.swift +++ /dev/null @@ -1,57 +0,0 @@ -import AppKit -import SwiftUI - -struct ERDiagramCanvasContainer: NSViewRepresentable { - let viewModel: ERDiagramViewModel - @ViewBuilder let content: () -> Content - - func makeNSView(context: Context) -> ERDiagramCanvasContainerView { - ERDiagramCanvasContainerView(rootView: content(), viewModel: viewModel) - } - - func updateNSView(_ nsView: ERDiagramCanvasContainerView, context: Context) { - nsView.hostingView.rootView = content() - } -} - -@MainActor -final class ERDiagramCanvasContainerView: NSView { - let hostingView: NSHostingView - private let viewModel: ERDiagramViewModel - - init(rootView: Content, viewModel: ERDiagramViewModel) { - self.viewModel = viewModel - hostingView = NSHostingView(rootView: rootView) - super.init(frame: .zero) - hostingView.translatesAutoresizingMaskIntoConstraints = false - addSubview(hostingView) - NSLayoutConstraint.activate([ - hostingView.leadingAnchor.constraint(equalTo: leadingAnchor), - hostingView.trailingAnchor.constraint(equalTo: trailingAnchor), - hostingView.topAnchor.constraint(equalTo: topAnchor), - hostingView.bottomAnchor.constraint(equalTo: bottomAnchor) - ]) - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) not supported") - } - - override func scrollWheel(with event: NSEvent) { - let action = ERDiagramScrollTranslator.action( - scrollingDeltaX: event.scrollingDeltaX, - scrollingDeltaY: event.scrollingDeltaY, - hasPreciseScrollingDeltas: event.hasPreciseScrollingDeltas, - isZoomModifierActive: event.modifierFlags.contains(.command), - currentOffset: viewModel.canvasOffset, - currentMagnification: viewModel.magnification - ) - switch action { - case .pan(let offset): - viewModel.canvasOffset = offset - case .zoom(let magnification): - viewModel.zoom(to: magnification) - } - } -} diff --git a/TablePro/Views/ERDiagram/ERDiagramToolbar.swift b/TablePro/Views/ERDiagram/ERDiagramToolbar.swift index 6e426fbd1..163af6a9a 100644 --- a/TablePro/Views/ERDiagram/ERDiagramToolbar.swift +++ b/TablePro/Views/ERDiagram/ERDiagramToolbar.swift @@ -2,53 +2,17 @@ import SwiftUI struct ERDiagramToolbar: View { @Bindable var viewModel: ERDiagramViewModel + let viewport: DiagramViewportController let onExport: () -> Void var body: some View { - HStack(spacing: 8) { - Button { - viewModel.zoom(to: viewModel.magnification - 0.25) - } label: { - Image(systemName: "minus.magnifyingglass") - } - .buttonStyle(.borderless) - .accessibilityLabel(String(localized: "Zoom Out")) - - Button { - viewModel.zoom(to: 1.0) - } label: { - Text(verbatim: "\(Int(viewModel.magnification * 100))%") - .font(.system(.caption, design: .monospaced)) - .frame(width: 40) - .foregroundStyle(.secondary) - } - .buttonStyle(.plain) - .help(String(localized: "Reset Zoom")) - - Button { - viewModel.zoom(to: viewModel.magnification + 0.25) - } label: { - Image(systemName: "plus.magnifyingglass") - } - .buttonStyle(.borderless) - .accessibilityLabel(String(localized: "Zoom In")) - - Button { - viewModel.fitToWindow() - } label: { - Image(systemName: "arrow.up.left.and.arrow.down.right") - } - .buttonStyle(.borderless) - .accessibilityLabel(String(localized: "Fit to Window")) - .help(String(localized: "Fit to Window")) - + DiagramZoomToolbar(viewport: viewport) { Divider().frame(height: 16) Toggle(isOn: $viewModel.isCompactMode) { Image(systemName: "rectangle.compress.vertical") } .toggleStyle(.button) - .buttonStyle(.borderless) .help(String(localized: "Compact Mode")) .accessibilityLabel(String(localized: "Compact Mode")) @@ -57,7 +21,6 @@ struct ERDiagramToolbar: View { Image(systemName: "arrow.left.arrow.right") } .toggleStyle(.button) - .buttonStyle(.borderless) .help(String(localized: "Collapse junction tables into many-to-many relationships")) .accessibilityLabel(String(localized: "Collapse Junction Tables")) } @@ -69,14 +32,12 @@ struct ERDiagramToolbar: View { } label: { Image(systemName: "arrow.counterclockwise") } - .buttonStyle(.borderless) .help(String(localized: "Reset Layout")) .accessibilityLabel(String(localized: "Reset Layout")) Button(action: onExport) { Image(systemName: "square.and.arrow.up") } - .buttonStyle(.borderless) .help(String(localized: "Export as PNG")) .accessibilityLabel(String(localized: "Export as PNG")) @@ -85,14 +46,9 @@ struct ERDiagramToolbar: View { } label: { Image(systemName: "doc.plaintext") } - .buttonStyle(.borderless) .help(String(localized: "Export as SQL")) .accessibilityLabel(String(localized: "Export as SQL")) } - .padding(.horizontal, 12) - .padding(.vertical, 6) - .themeMaterial(.toolbar, .thinMaterial, in: Capsule()) - .overlay(Capsule().strokeBorder(.quaternary, lineWidth: 0.5)) .padding(12) } } diff --git a/TablePro/Views/ERDiagram/ERDiagramView.swift b/TablePro/Views/ERDiagram/ERDiagramView.swift index c1c2ef296..27d2cc04e 100644 --- a/TablePro/Views/ERDiagram/ERDiagramView.swift +++ b/TablePro/Views/ERDiagram/ERDiagramView.swift @@ -1,16 +1,18 @@ import AppKit -import os import SwiftUI -import UniformTypeIdentifiers struct ERDiagramView: View { @Bindable var viewModel: ERDiagramViewModel @Environment(\.accessibilityDifferentiateWithoutColor) private var differentiateWithoutColor + @State private var viewport = DiagramViewportController() @State private var selectedNodeId: UUID? @State private var currentCursor: NSCursor? - @State private var magnifyStartMag: CGFloat? + @State private var lastPanTranslation: CGSize = .zero - private static let logger = Logger(subsystem: "com.TablePro", category: "ERDiagramView") + /// The scroll view reports no visible rect until AppKit has laid it out, which happens after + /// SwiftUI mounts it. The fit retries across a bounded number of main-actor hops rather than + /// waiting on a wall-clock delay. + private static let fitLayoutAttempts = 30 var body: some View { ZStack(alignment: .bottomTrailing) { @@ -47,66 +49,56 @@ struct ERDiagramView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) } else { - ERDiagramCanvasContainer(viewModel: viewModel) { diagramContent } + MagnifiableCanvasView( + viewport: viewport, + contentSize: viewModel.cachedCanvasSize, + accessibilityIdentifier: "er-diagram-canvas" + ) { + diagramContent + } } - ERDiagramToolbar(viewModel: viewModel, onExport: exportDiagram) + ERDiagramToolbar(viewModel: viewModel, viewport: viewport, onExport: exportDiagram) } } - .onCopyCommand { copyDiagramItemProviders() } - .task { await viewModel.loadDiagram() } + .onCopyCommand { DiagramImageExporter.copyItemProviders(of: makeExportView()) } + .task { + viewModel.viewport = viewport + await viewModel.loadDiagram() + } + .task(id: viewModel.loadState) { await fitWhenLaidOut() } } // MARK: - Diagram Content private var diagramContent: some View { - GeometryReader { proxy in - let nodeRects = viewModel.cachedNodeRects - let edges = viewModel.graph.edges - let nodes = viewModel.graph.nodes - let nodeIndex = viewModel.graph.nodeIndex - let selectedId = selectedNodeId - let mag = viewModel.magnification - let offset = viewModel.canvasOffset - let clusterColors = nodeClusterColors(nodes: nodes) + let nodeRects = viewModel.cachedNodeRects + let edges = viewModel.graph.edges + let nodes = viewModel.graph.nodes + let nodeIndex = viewModel.graph.nodeIndex + let selectedId = selectedNodeId + let clusterColors = nodeClusterColors(nodes: nodes) + let canvasSize = viewModel.cachedCanvasSize - Canvas { context, _ in - context.translateBy(x: offset.x, y: offset.y) - context.scaleBy(x: mag, y: mag) + return Canvas { context, _ in + ERDiagramEdgeRenderer.drawEdges( + context: context, + edges: edges, + nodeRects: nodeRects, + nodeIndex: nodeIndex + ) - ERDiagramEdgeRenderer.drawEdges( - context: context, - edges: edges, - nodeRects: nodeRects, - nodeIndex: nodeIndex + for node in nodes { + guard let rect = nodeRects[node.id] else { continue } + ERDiagramNodeRenderer.drawNode( + context: &context, + node: node, + rect: rect, + isSelected: selectedId == node.id, + clusterColor: clusterColors[node.id] ) - - for node in nodes { - guard let rect = nodeRects[node.id] else { continue } - ERDiagramNodeRenderer.drawNode( - context: &context, - node: node, - rect: rect, - isSelected: selectedId == node.id, - clusterColor: clusterColors[node.id] - ) - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .onAppear { - viewModel.viewportSize = proxy.size - if viewModel.needsInitialFit && proxy.size.width > 0 { - viewModel.fitToWindow() - viewModel.needsInitialFit = false - } - } - .onChange(of: proxy.size) { _, newSize in - viewModel.viewportSize = newSize - if viewModel.needsInitialFit && newSize.width > 0 { - viewModel.fitToWindow() - viewModel.needsInitialFit = false - } } } + .frame(width: canvasSize.width, height: canvasSize.height) .contentShape(Rectangle()) .accessibilityElement() .accessibilityLabel(Text("\(viewModel.graph.nodes.count) tables, \(viewModel.graph.edges.count) relationships")) @@ -114,7 +106,7 @@ struct ERDiagramView: View { .onTapGesture { location in selectedNodeId = nodeAt(point: location) } - .gesture(combinedGesture.simultaneously(with: magnifyGesture)) + .gesture(canvasGesture) .onContinuousHover { phase in switch phase { case .active(let location): @@ -136,6 +128,22 @@ struct ERDiagramView: View { } } + // MARK: - Initial Fit + + private func fitWhenLaidOut() async { + guard viewModel.loadState == .loaded else { return } + for _ in 0.. 0, visible.height > 0 { + viewport.fitToWindow() + viewModel.needsInitialFit = false + return + } + await Task.yield() + } + } + // MARK: - Cluster Colors private func nodeClusterColors(nodes: [ERTableNode]) -> [UUID: Color] { @@ -152,37 +160,43 @@ struct ERDiagramView: View { // MARK: - Hit Testing private func nodeAt(point: CGPoint) -> UUID? { - let canvasPoint = CGPoint( - x: (point.x - viewModel.canvasOffset.x) / viewModel.magnification, - y: (point.y - viewModel.canvasOffset.y) / viewModel.magnification - ) - for (id, rect) in viewModel.cachedNodeRects where rect.contains(canvasPoint) { - return id - } - return nil + viewModel.cachedNodeRects.first { $0.value.contains(point) }?.key } - // MARK: - Combined Gesture (pan + node drag) + // MARK: - Canvas Gesture (pan + node drag) - private var combinedGesture: some Gesture { + /// The local drag reports document coordinates, which is what hit testing and node dragging + /// need. Panning reads the global drag instead, because scrolling the document moves the + /// local space underneath the pointer and a local translation would cancel itself out. + private var canvasGesture: some Gesture { DragGesture(minimumDistance: 2) + .simultaneously(with: DragGesture(minimumDistance: 2, coordinateSpace: .global)) .onChanged { value in + guard let local = value.first else { return } if !viewModel.isDragging { - viewModel.beginDrag(at: value.startLocation) + viewModel.beginDrag(at: local.startLocation) if viewModel.draggingNodeId != nil { if currentCursor != nil { NSCursor.pop() } NSCursor.closedHand.push() currentCursor = .closedHand } } - let currentPoint = CGPoint( - x: value.startLocation.x + value.translation.width, - y: value.startLocation.y + value.translation.height - ) - viewModel.updateDrag(translation: value.translation, currentPoint: currentPoint) + + if viewModel.draggingNodeId != nil { + let currentPoint = CGPoint( + x: local.startLocation.x + local.translation.width, + y: local.startLocation.y + local.translation.height + ) + viewModel.updateDrag(translation: local.translation, currentPoint: currentPoint) + return + } + + guard let global = value.second else { return } + panCanvas(to: global.translation) } .onEnded { _ in viewModel.endDrag() + lastPanTranslation = .zero if currentCursor != nil { NSCursor.pop() currentCursor = nil @@ -190,21 +204,14 @@ struct ERDiagramView: View { } } - // MARK: - Pinch-to-Zoom - - private var magnifyGesture: some Gesture { - MagnifyGesture() - .onChanged { value in - if magnifyStartMag == nil { - magnifyStartMag = viewModel.magnification - } - let base = magnifyStartMag ?? viewModel.magnification - let newMag = max(0.25, min(3.0, base * value.magnification)) - viewModel.zoom(to: newMag, anchor: value.startLocation) - } - .onEnded { _ in - magnifyStartMag = nil - } + private func panCanvas(to translation: CGSize) { + let delta = CGSize( + width: translation.width - lastPanTranslation.width, + height: translation.height - lastPanTranslation.height + ) + lastPanTranslation = translation + let magnification = max(viewport.magnification, 0.01) + viewport.scrollBy(CGSize(width: -delta.width / magnification, height: -delta.height / magnification)) } // MARK: - Export Rendering @@ -246,63 +253,11 @@ struct ERDiagramView: View { .background(Color(nsColor: .controlBackgroundColor)) } - /// Feeds the standard Edit > Copy command, so the diagram follows whatever shortcut the - /// user has bound instead of a hardcoded Cmd+C on the toolbar. - private func copyDiagramItemProviders() -> [NSItemProvider] { - let renderer = ImageRenderer(content: makeExportView()) - renderer.scale = 2.0 - guard let image = renderer.nsImage else { return [] } - return [NSItemProvider(object: image)] - } - private func exportDiagram() { - let renderer = ImageRenderer(content: makeExportView()) - renderer.scale = 2.0 - - guard let image = renderer.nsImage else { - Self.logger.error("Failed to render ER diagram to image") - let alert = NSAlert() - alert.messageText = String(localized: "Export Failed") - alert.informativeText = String(localized: "Failed to render the diagram image.") - alert.alertStyle = .warning - if let window = AlertHelper.resolveWindow(nil) { - alert.beginSheetModal(for: window) - } else { - alert.runModal() - } - return - } - - let panel = NSSavePanel() - panel.allowedContentTypes = [.png] - panel.nameFieldStringValue = "er-diagram.png" - panel.title = String(localized: "Export ER Diagram") - panel.message = String(localized: "Choose a location to save the diagram as PNG.") - - guard let window = AlertHelper.resolveWindow(nil) else { return } - panel.beginSheetModal(for: window) { response in - guard response == .OK, let url = panel.url else { return } - guard let tiffData = image.tiffRepresentation, - let bitmap = NSBitmapImageRep(data: tiffData), - let pngData = bitmap.representation(using: .png, properties: [:]) - else { - AlertHelper.showErrorSheet( - title: String(localized: "Could not export the diagram"), - message: String(localized: "The diagram could not be converted to a PNG image."), - window: window - ) - return - } - do { - try pngData.write(to: url) - } catch { - Self.logger.error("Failed to write PNG: \(error.localizedDescription)") - AlertHelper.showErrorSheet( - title: String(localized: "Could not export the diagram"), - message: error.localizedDescription, - window: window - ) - } - } + DiagramImageExporter.export( + makeExportView(), + defaultFileName: "er-diagram.png", + title: String(localized: "Export ER Diagram") + ) } } diff --git a/TablePro/Views/Editor/ExplainResultView.swift b/TablePro/Views/Editor/ExplainResultView.swift deleted file mode 100644 index 2d49aeecd..000000000 --- a/TablePro/Views/Editor/ExplainResultView.swift +++ /dev/null @@ -1,143 +0,0 @@ -// -// ExplainResultView.swift -// TablePro -// -// Displays EXPLAIN query results with toggle between diagram, tree, and raw text. -// - -import SwiftUI - -private enum ExplainViewMode: String, CaseIterable { - case diagram = "Diagram" - case tree = "Tree" - case raw = "Raw" -} - -struct ExplainResultView: View { - let text: String - let executionTime: TimeInterval? - let plan: QueryPlan? - - @State private var fontSize: Double = 13 - @State private var showCopyConfirmation = false - @State private var copyResetTask: Task? - @State private var viewMode: ExplainViewMode = .diagram - - var body: some View { - VStack(spacing: 0) { - toolbar - Divider() - switch viewMode { - case .diagram: - if let plan { - QueryPlanDiagramView(plan: plan) - } else { - DDLTextView(ddl: text, fontSize: $fontSize) - } - case .tree: - if let plan { - QueryPlanTreeView(plan: plan) - } else { - DDLTextView(ddl: text, fontSize: $fontSize) - } - case .raw: - DDLTextView(ddl: text, fontSize: $fontSize) - } - } - } - - private var toolbar: some View { - HStack(spacing: 12) { - if plan != nil { - Picker("", selection: $viewMode) { - Text(String(localized: "Diagram")).tag(ExplainViewMode.diagram) - Text(String(localized: "Tree")).tag(ExplainViewMode.tree) - Text(String(localized: "Raw")).tag(ExplainViewMode.raw) - } - .pickerStyle(.segmented) - .controlSize(.small) - .frame(width: 240) - .labelsHidden() - } - - if viewMode == .raw || plan == nil { - HStack(spacing: 4) { - Button(action: { fontSize = max(10, fontSize - 1) }) { - Image(systemName: "textformat.size.smaller") - .frame(width: 24, height: 24) - } - .accessibilityLabel(String(localized: "Decrease font size")) - Text("\(Int(fontSize))") - .font(.caption) - .foregroundStyle(.secondary) - .frame(width: 24) - Button(action: { fontSize = min(24, fontSize + 1) }) { - Image(systemName: "textformat.size.larger") - .frame(width: 24, height: 24) - } - .accessibilityLabel(String(localized: "Increase font size")) - } - .buttonStyle(.borderless) - } - - if let plan { - if let planTime = plan.planningTime { - Text(String(format: String(localized: "Planning: %.3fms"), planTime)) - .font(.caption) - .foregroundStyle(.secondary) - } - if let execTime = plan.executionTime { - Text(String(format: String(localized: "Execution: %.3fms"), execTime)) - .font(.caption) - .foregroundStyle(.secondary) - } - } else if let time = executionTime { - Text(formattedDuration(time)) - .font(.caption) - .foregroundStyle(.secondary) - } - - Spacer() - - if showCopyConfirmation { - HStack { - Image(systemName: "checkmark.circle.fill") - .foregroundStyle(.green) - Text(String(localized: "Copied!")) - } - .transition(.opacity) - } - - Button(action: copyText) { - Label(String(localized: "Copy"), systemImage: "doc.on.doc") - } - .buttonStyle(.bordered) - .controlSize(.small) - .help(String(localized: "Copy EXPLAIN output to clipboard")) - } - .padding(.horizontal, 12) - .padding(.vertical, 8) - .background(Color(nsColor: .controlBackgroundColor)) - } - - private func copyText() { - ClipboardService.shared.writeText(text) - withAnimation { showCopyConfirmation = true } - copyResetTask?.cancel() - copyResetTask = Task { @MainActor in - try? await Task.sleep(for: .milliseconds(1_500)) - guard !Task.isCancelled else { return } - withAnimation { showCopyConfirmation = false } - } - } - - private func formattedDuration(_ duration: TimeInterval) -> String { - if duration < 0.001 { - return "<1ms" - } else if duration < 1.0 { - return String(format: "%.0fms", duration * 1_000) - } else { - return String(format: "%.2fs", duration) - } - } -} diff --git a/TablePro/Views/Editor/QueryEditorView.swift b/TablePro/Views/Editor/QueryEditorView.swift index 7536ce6e4..810214481 100644 --- a/TablePro/Views/Editor/QueryEditorView.swift +++ b/TablePro/Views/Editor/QueryEditorView.swift @@ -27,8 +27,7 @@ struct QueryEditorView: View { var restoredCursorRange: NSRange? var onCloseTab: (() -> Void)? var onExecuteQuery: (() -> Void)? - var onExplain: ((ClickHouseExplainVariant?) -> Void)? - var onExplainVariant: ((ExplainVariant) -> Void)? + var onExplain: ((ExplainVariant?) -> Void)? var onAIExplain: ((String) -> Void)? var onAIOptimize: ((String) -> Void)? var onSaveAsFavorite: ((String) -> Void)? @@ -177,15 +176,7 @@ struct QueryEditorView: View { if variants.count <= 1 { Button { - if let variant = variants.first { - if let handler = onExplainVariant { - handler(variant) - } else { - onExplain?(nil) - } - } else { - onExplain?(nil) - } + onExplain?(variants.first) } label: { HStack(spacing: 4) { Image(systemName: "chart.bar.doc.horizontal") @@ -199,13 +190,7 @@ struct QueryEditorView: View { } else { Menu { ForEach(variants) { variant in - Button(variant.label) { - if let handler = onExplainVariant { - handler(variant) - } else if let legacy = ClickHouseExplainVariant(rawValue: variant.label) { - onExplain?(legacy) - } - } + Button(variant.label) { onExplain?(variant) } } } label: { HStack(spacing: 4) { diff --git a/TablePro/Views/Editor/QuerySplitView.swift b/TablePro/Views/Editor/QuerySplitView.swift index df9de5485..dcbc82ebc 100644 --- a/TablePro/Views/Editor/QuerySplitView.swift +++ b/TablePro/Views/Editor/QuerySplitView.swift @@ -26,6 +26,12 @@ struct QuerySplitView: NSViewControllerRe bottomItem.canCollapse = true bottomItem.minimumThickness = 150 + // Without this the hosting controllers report their content's ideal size as a + // preferredContentSize, which this split view forwards to the window: wide results + // content then outranks a divider drag and the editor/results divider stops moving. + topController.sizingOptions = [] + bottomController.sizingOptions = [] + splitViewController.addSplitViewItem(topItem) splitViewController.addSplitViewItem(bottomItem) diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index b38f8b75f..0e72b97ca 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -376,16 +376,7 @@ struct MainEditorContentView: View { coordinator.commandActions?.closeTab() }, onExecuteQuery: { coordinator.runQuery() }, - onExplain: { variant in - if let variant { - coordinator.runClickHouseExplain(variant: variant) - } else { - coordinator.runExplainQuery() - } - }, - onExplainVariant: { variant in - coordinator.runVariantExplain(variant) - }, + onExplain: { variant in coordinator.runExplain(variant: variant) }, onAIExplain: { text in coordinator.showAIChatPanel() coordinator.aiViewModel?.handleExplainSelection(text) @@ -564,12 +555,16 @@ struct MainEditorContentView: View { ) .id(tab.id) case .data: - if let explainText = tab.display.explainText { - ExplainResultView(text: explainText, executionTime: tab.display.explainExecutionTime, plan: tab.display.explainPlan) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else { - resultTabBarSection(tab: tab) + resultTabBarSection(tab: tab) + if let explain = tab.display.activeExplainResult { + QueryPlanResultView( + rawText: explain.explainRawText ?? "", + executionTime: explain.executionTime, + plan: explain.queryPlan + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { let resolvedRows = resolvedTableRows(for: tab) if let rs = tab.display.activeResultSet, rs.resultColumns.isEmpty, rs.errorMessage == nil, tab.execution.lastExecutedAt != nil, @@ -622,7 +617,7 @@ struct MainEditorContentView: View { } } - if tab.display.explainText == nil { + if tab.display.activeExplainResult == nil { Divider() statusBar(tab: tab) } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+ClickHouse.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+ClickHouse.swift index 36ad1599c..a25a97ffd 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+ClickHouse.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+ClickHouse.swift @@ -2,12 +2,10 @@ // MainContentCoordinator+ClickHouse.swift // TablePro // -// ClickHouse-specific coordinator methods: progress tracking, EXPLAIN variants. +// ClickHouse-specific coordinator methods: progress tracking. // -import CodeEditSourceEditor import Foundation -import TableProPluginKit extension MainContentCoordinator { func installClickHouseProgressHandler() { @@ -21,87 +19,4 @@ extension MainContentCoordinator { } toolbarState.clickHouseProgress = nil } - - /// Run EXPLAIN with a specific variant (e.g. ClickHouse Plan/Pipeline/AST). - /// Accepts the plugin-kit `ExplainVariant` type for generic dispatch. - func runVariantExplain(_ variant: ExplainVariant) { - guard let (tab, _) = tabManager.selectedTabAndIndex, - !tabExecution.isExecuting(tab.id) else { return } - - let fullQuery = tab.content.query - - let sql: String - if tab.tabType == .table { - sql = fullQuery - } else if let firstCursor = cursorPositions.first, - firstCursor.range.length > 0 { - let nsQuery = fullQuery as NSString - let clampedRange = NSIntersectionRange( - firstCursor.range, - NSRange(location: 0, length: nsQuery.length) - ) - sql = nsQuery.substring(with: clampedRange) - .trimmingCharacters(in: .whitespacesAndNewlines) - } else { - sql = SQLStatementScanner.statementAtCursor( - in: fullQuery, - cursorPosition: cursorPositions.first?.range.location ?? 0, - dialect: sqlDialect - ) - } - - let trimmed = sql.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return } - - let statements = SQLStatementScanner.allStatements(in: trimmed, dialect: sqlDialect) - guard let stmt = statements.first else { return } - - let explainSQL = "\(variant.sqlPrefix) \(stmt)" - let tabId = tab.id - - Task { - guard let driver = DatabaseManager.shared.driver(for: connectionId) else { return } - - toolbarState.setExecuting(true) - - do { - let startTime = Date() - let result = try await driver.execute(query: explainSQL) - let duration = Date().timeIntervalSince(startTime) - - let text = result.rows.map { row in - row.compactMap { $0.asText }.joined(separator: "\t") - }.joined(separator: "\n") - - let parser = QueryPlanParserFactory.parser(for: connection.type) - tabManager.mutate(tabId: tabId) { tab in - tab.display.explainText = text - tab.display.explainExecutionTime = duration - - if let parser { - tab.display.explainPlan = parser.parse(rawText: text) - } else { - tab.display.explainPlan = nil - } - } - } catch { - tabManager.mutate(tabId: tabId) { tab in - tab.display.explainText = "Error: \(error.localizedDescription)" - tab.display.explainPlan = nil - } - } - - toolbarState.setExecuting(false) - } - } - - /// Legacy bridge: calls runVariantExplain with the matching ExplainVariant. - func runClickHouseExplain(variant: ClickHouseExplainVariant) { - let pluginVariant = ExplainVariant( - id: variant.rawValue.lowercased(), - label: variant.rawValue, - sqlPrefix: variant.sqlKeyword - ) - runVariantExplain(pluginVariant) - } } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Explain.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Explain.swift new file mode 100644 index 000000000..b670ad950 --- /dev/null +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Explain.swift @@ -0,0 +1,211 @@ +// +// MainContentCoordinator+Explain.swift +// TablePro +// +// The one path that runs EXPLAIN, whatever asked for it: the toolbar button, a variant picked +// from its menu, or the Query menu item. Every one of them authorizes through the execution +// gate and is fenced against a superseded result, the same way a normal query is. +// + +import CodeEditSourceEditor +import Foundation +import TableProPluginKit + +extension MainContentCoordinator { + func runExplain(variant: ExplainVariant? = nil) { + guard let (tab, index) = tabManager.selectedTabAndIndex else { return } + guard !tabExecution.isExecuting(tab.id) else { + traceExecutionBlocked(tabId: tab.id, site: "runExplain") + return + } + guard let statement = explainStatement(in: tab) else { return } + guard let request = explainRequest(variant: variant, statement: statement) else { + tabManager.mutate(at: index) { + $0.execution.errorMessage = String( + localized: "EXPLAIN is not supported for this database type." + ) + } + return + } + + let level = safeModeLevel + guard level.appliesToAllQueries, level.requiresConfirmation else { + run(request) + return + } + + Task { + let decision = await ExecutionGateProvider.shared.authorize( + OperationRequest( + connectionId: connectionId, + databaseType: connection.type, + sql: request.sql, + kind: .readQuery, + caller: .userInterface, + capabilities: .interactiveUser, + operationDescription: String(localized: "Execute Query") + ) + ) + guard case .authorized = decision else { return } + run(request) + } + } + + // MARK: - Request + + private func explainStatement(in tab: QueryTab) -> String? { + let fullQuery = tab.content.query + + let sql: String + if tab.tabType == .table { + sql = fullQuery + } else if let firstCursor = cursorPositions.first, firstCursor.range.length > 0 { + let nsQuery = fullQuery as NSString + let clampedRange = NSIntersectionRange( + firstCursor.range, + NSRange(location: 0, length: nsQuery.length) + ) + sql = nsQuery.substring(with: clampedRange).trimmingCharacters(in: .whitespacesAndNewlines) + } else { + sql = SQLStatementScanner.statementAtCursor( + in: fullQuery, + cursorPosition: cursorPositions.first?.range.location ?? 0, + dialect: sqlDialect + ) + } + + let trimmed = sql.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + return SQLStatementScanner.allStatements(in: trimmed, dialect: sqlDialect).first + } + + private func explainRequest(variant: ExplainVariant?, statement: String) -> ExplainRequest? { + if let request = ExplainRequest.make( + variant: variant, + declaredVariants: connection.type.explainVariants, + databaseType: connection.type, + statement: statement + ) { + return request + } + + guard let adapter = services.databaseManager.driver(for: connectionId) as? PluginDriverAdapter, + let fallbackSQL = adapter.buildExplainQuery(statement) + else { return nil } + + return ExplainRequest.driverBuilt(sql: fallbackSQL, databaseType: connection.type) + } + + // MARK: - Execution + + private func run(_ request: ExplainRequest) { + guard !request.isDriverBuilt else { + executeQueryInternal(request.sql) + return + } + executeExplain(request) + } + + private func executeExplain(_ request: ExplainRequest) { + guard let (tab, index) = tabManager.selectedTabAndIndex else { return } + guard let scope = scope(for: tab) else { + tabManager.mutate(at: index) { + $0.execution.errorMessage = String(localized: "Not connected to database") + } + return + } + + supersedeExecution(for: tab.id) + let claim = tabExecution.claim(tab.id) + let tabId = tab.id + let conn = connection + + tabManager.mutate(at: index) { $0.execution.errorMessage = nil } + toolbarState.setExecuting(true) + + currentQueryTask = Task { [weak self] in + guard let self else { return } + do { + let fetchResult = try await services.databaseManager.withScopedDriver( + scope: scope, + route: services.databaseManager.executionRoute(for: scope), + cancellation: .cancellableRead + ) { [queryExecutor] driver in + try await queryExecutor.executeQuery( + driver: driver, sql: request.sql, parameters: nil, rowCap: nil + ) + } + let rawText = ExplainPlanTextFlattener.flatten(rows: fetchResult.rows) + let plan = ExplainPlanParserRegistry.plan(from: rawText, format: request.format) + + await MainActor.run { [weak self] in + guard let self else { return } + + // Every write below belongs to whoever owns the tab now. A superseded plan + // that cleared the spinner or nilled the task handle would be reporting on a + // query that is still running, so the gate comes before all of them. + guard tabExecution.isCurrent(claim), !Task.isCancelled else { return } + currentQueryTask = nil + toolbarState.setExecuting(false) + + tabManager.mutate(tabId: tabId) { tab in + tab.execution.executionTime = fetchResult.executionTime + tab.execution.rowsAffected = 0 + tab.execution.statusMessage = nil + tab.execution.lastExecutedAt = Date() + tab.pagination.resetLoadMore() + tab.display.replaceUnpinnedResults( + with: [ExplainResultSetFactory.make( + rawText: rawText, plan: plan, sql: request.sql, + executionTime: fetchResult.executionTime + )] + ) + if tab.display.isResultsCollapsed { + tab.display.isResultsCollapsed = false + } + } + toolbarState.isResultsCollapsed = false + tabExecution.settle(claim) + + QueryHistoryManager.shared.recordQuery( + query: request.sql, + connectionId: conn.id, + databaseName: queryExecutionCoordinator.historyDatabaseName(tabId: tabId), + executionTime: fetchResult.executionTime, + rowCount: fetchResult.rows.count, + wasSuccessful: true, + errorMessage: nil, + parameterValues: nil + ) + } + } catch { + await MainActor.run { [weak self] in + guard let self else { return } + tabExecution.settle(claim) + currentQueryTask = nil + toolbarState.setExecuting(false) + + // A cancelled EXPLAIN is not a failure the user needs told about, and it does + // not belong in history either. + if DatabaseCancellationDiagnosis.isCancellation(error) || Task.isCancelled { return } + guard tabExecution.isCurrent(claim) else { return } + + tabManager.mutate(tabId: tabId) { tab in + tab.execution.errorMessage = error.localizedDescription + } + + QueryHistoryManager.shared.recordQuery( + query: request.sql, + connectionId: conn.id, + databaseName: queryExecutionCoordinator.historyDatabaseName(tabId: tabId), + executionTime: 0, + rowCount: 0, + wasSuccessful: false, + errorMessage: error.localizedDescription, + parameterValues: nil + ) + } + } + } + } +} diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index cf520f0af..6ef52425b 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -816,7 +816,7 @@ final class MainContentCommandActions { } func explainQuery() { - coordinator?.runExplainQuery() + coordinator?.runExplain() } func aiExplainQuery() { diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 1d2a7ce1d..28363b8fa 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -1024,107 +1024,6 @@ final class MainContentCoordinator { } } - /// Run EXPLAIN on the current query (database-type-aware prefix) - func runExplainQuery() { - guard let (tab, _) = tabManager.selectedTabAndIndex else { return } - guard !tabExecution.isExecuting(tab.id) else { - traceExecutionBlocked(tabId: tab.id, site: "runExplainQuery") - return - } - - let fullQuery = tab.content.query - - let sql: String - if tab.tabType == .table { - sql = fullQuery - } else if let firstCursor = cursorPositions.first, - firstCursor.range.length > 0 { - let nsQuery = fullQuery as NSString - let clampedRange = NSIntersectionRange( - firstCursor.range, - NSRange(location: 0, length: nsQuery.length) - ) - sql = nsQuery.substring(with: clampedRange) - .trimmingCharacters(in: .whitespacesAndNewlines) - } else { - sql = SQLStatementScanner.statementAtCursor( - in: fullQuery, - cursorPosition: cursorPositions.first?.range.location ?? 0, - dialect: sqlDialect - ) - } - - let trimmed = sql.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return } - - // Use first statement only (EXPLAIN on a single statement) - let statements = SQLStatementScanner.allStatements(in: trimmed, dialect: sqlDialect) - guard let stmt = statements.first else { return } - - let level = safeModeLevel - let needsConfirmation = level.appliesToAllQueries && level.requiresConfirmation - - // Multi-variant EXPLAIN: use plugin-declared variants if available - let explainVariants = connection.type.explainVariants - - if !explainVariants.isEmpty { - if needsConfirmation { - Task { - let decision = await ExecutionGateProvider.shared.authorize( - OperationRequest( - connectionId: connectionId, - databaseType: connection.type, - sql: "EXPLAIN", - kind: .readQuery, - caller: .userInterface, - capabilities: .interactiveUser, - operationDescription: String(localized: "Execute Query") - ) - ) - if case .authorized = decision { - runVariantExplain(explainVariants[0]) - } - } - } else { - runVariantExplain(explainVariants[0]) - } - return - } - - guard let adapter = services.databaseManager.driver(for: connectionId) as? PluginDriverAdapter, - let explainSQL = adapter.buildExplainQuery(stmt) else { - if let (_, index) = tabManager.selectedTabAndIndex { - tabManager.mutate(at: index) { - $0.execution.errorMessage = String(localized: "EXPLAIN is not supported for this database type.") - } - } - return - } - - if needsConfirmation { - Task { - let decision = await ExecutionGateProvider.shared.authorize( - OperationRequest( - connectionId: connectionId, - databaseType: connection.type, - sql: explainSQL, - kind: .readQuery, - caller: .userInterface, - capabilities: .interactiveUser, - operationDescription: String(localized: "Execute Query") - ) - ) - if case .authorized = decision { - executeQueryInternal(explainSQL) - } - } - } else { - Task { - executeQueryInternal(explainSQL) - } - } - } - internal func executeQueryInternal( _ sql: String, isAutoLoad: Bool = false, @@ -1139,8 +1038,6 @@ final class MainContentCoordinator { tabManager.mutate(at: index) { tab in tab.execution.executionTime = nil tab.execution.errorMessage = nil - tab.display.explainText = nil - tab.display.explainPlan = nil } let tab = tabManager.tabs[index] toolbarState.setExecuting(true) diff --git a/TablePro/Views/QueryPlan/QueryPlanDetailPane.swift b/TablePro/Views/QueryPlan/QueryPlanDetailPane.swift new file mode 100644 index 000000000..82f55eaf6 --- /dev/null +++ b/TablePro/Views/QueryPlan/QueryPlanDetailPane.swift @@ -0,0 +1,105 @@ +// +// QueryPlanDetailPane.swift +// TablePro +// +// Everything a plan node reports. Shared by the diagram's popover and the tree's detail pane. +// + +import SwiftUI + +struct QueryPlanDetailPane: View { + let node: QueryPlanNode? + + var body: some View { + if let node { + content(for: node) + } else { + ContentUnavailableView { + Label(String(localized: "No Node Selected"), systemImage: "square.dashed") + } description: { + Text(String(localized: "Select a step in the plan to see what it does.")) + } + .background(Color(nsColor: .controlBackgroundColor)) + } + } + + private func content(for node: QueryPlanNode) -> some View { + ScrollView(.vertical) { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 6) { + Text(node.operation) + .font(.headline) + Spacer(minLength: 8) + Button { + ClipboardService.shared.writeText(QueryPlanNodeSummary.text(for: node)) + } label: { + Image(systemName: "doc.on.doc") + } + .buttonStyle(.borderless) + .accessibilityLabel(String(localized: "Copy Node Details")) + .help(String(localized: "Copy Node Details")) + } + + estimates(for: node) + actuals(for: node) + properties(for: node) + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + } + .background(Color(nsColor: .controlBackgroundColor)) + .accessibilityIdentifier("query-plan-detail-pane") + } + + @ViewBuilder + private func estimates(for node: QueryPlanNode) -> some View { + VStack(alignment: .leading, spacing: 4) { + if let relation = node.relation { detailRow(QueryPlanLabels.table, relation) } + if let cost = node.costRangeText(fractionDigits: 2) { detailRow(QueryPlanLabels.cost, cost) } + if let rows = node.estimatedRows { detailRow(QueryPlanLabels.rows, "\(rows)") } + if let width = node.estimatedWidth, width > 0 { detailRow(QueryPlanLabels.width, "\(width)") } + } + } + + @ViewBuilder + private func actuals(for node: QueryPlanNode) -> some View { + if let time = node.actualTotalTime { + Divider() + VStack(alignment: .leading, spacing: 4) { + Text(QueryPlanLabels.actual) + .font(.caption.weight(.semibold)) + detailRow(QueryPlanLabels.actualTime, QueryPlanLabels.milliseconds(time)) + if let rows = node.actualRows { detailRow(QueryPlanLabels.actualRows, "\(rows)") } + if let loops = node.actualLoops, loops > 1 { detailRow(QueryPlanLabels.loops, "\(loops)") } + } + } + } + + @ViewBuilder + private func properties(for node: QueryPlanNode) -> some View { + let visible = QueryPlanLabels.visibleProperties(of: node) + if !visible.isEmpty { + Divider() + VStack(alignment: .leading, spacing: 4) { + Text(QueryPlanLabels.details) + .font(.caption.weight(.semibold)) + ForEach(visible, id: \.key) { key, value in + detailRow(key, value) + } + } + } + } + + private func detailRow(_ label: String, _ value: String) -> some View { + HStack(alignment: .top, spacing: 8) { + Text(label) + .font(.caption) + .foregroundStyle(.secondary) + .frame(width: 90, alignment: .trailing) + Text(value) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } + } +} diff --git a/TablePro/Views/QueryPlan/QueryPlanDiagramLayout.swift b/TablePro/Views/QueryPlan/QueryPlanDiagramLayout.swift new file mode 100644 index 000000000..5c0185af1 --- /dev/null +++ b/TablePro/Views/QueryPlan/QueryPlanDiagramLayout.swift @@ -0,0 +1,134 @@ +// +// QueryPlanDiagramLayout.swift +// TablePro +// +// Geometry for the EXPLAIN plan diagram: one row per tree depth, children centered +// under their parent. +// + +import CoreGraphics +import Foundation + +enum QueryPlanDiagramMetrics { + static let nodeWidth: CGFloat = 200 + static let nodeMinHeight: CGFloat = 50 + static let horizontalSpacing: CGFloat = 24 + static let verticalSpacing: CGFloat = 40 + static let nodePadding: CGFloat = 8 + static let cornerRadius: CGFloat = 6 + static let arrowHeadSize: CGFloat = 6 +} + +struct QueryPlanDiagramLayout { + struct Node: Identifiable { + let id: UUID + let node: QueryPlanNode + let rect: CGRect + let parentId: UUID? + } + + let nodes: [Node] + let canvasSize: CGSize + + init(root: QueryPlanNode) { + let rowOffsets = Self.rowOffsets(root) + let nodes = Self.position(root, depth: 0, xOffset: 0, parentId: nil, rowOffsets: rowOffsets) + self.nodes = nodes + canvasSize = Self.canvasSize(of: nodes) + } + + // MARK: - Rows + + /// The top edge of every depth, stacked by the tallest node in each row so siblings never + /// drift apart and a child never lands inside its parent. + private static func rowOffsets(_ root: QueryPlanNode) -> [CGFloat] { + var heights: [CGFloat] = [] + + func measure(_ node: QueryPlanNode, depth: Int) { + let height = nodeHeight(node) + if depth < heights.count { + heights[depth] = max(heights[depth], height) + } else { + heights.append(height) + } + for child in node.children { measure(child, depth: depth + 1) } + } + measure(root, depth: 0) + + var offsets: [CGFloat] = [] + var top = QueryPlanDiagramMetrics.verticalSpacing + for height in heights { + offsets.append(top) + top += height + QueryPlanDiagramMetrics.verticalSpacing + } + return offsets + } + + private static func nodeHeight(_ node: QueryPlanNode) -> CGFloat { + var height: CGFloat = 18 + if node.relation != nil { height += 14 } + if node.estimatedTotalCost != nil || node.estimatedRows != nil { height += 12 } + if node.actualTotalTime != nil { height += 12 } + return max( + QueryPlanDiagramMetrics.nodeMinHeight, + height + QueryPlanDiagramMetrics.nodePadding * 2 + ) + } + + // MARK: - Placement + + private static func position( + _ node: QueryPlanNode, + depth: Int, + xOffset: CGFloat, + parentId: UUID?, + rowOffsets: [CGFloat] + ) -> [Node] { + let size = CGSize(width: QueryPlanDiagramMetrics.nodeWidth, height: nodeHeight(node)) + let top = depth < rowOffsets.count ? rowOffsets[depth] : QueryPlanDiagramMetrics.verticalSpacing + + guard !node.children.isEmpty else { + let rect = CGRect( + origin: CGPoint(x: xOffset + QueryPlanDiagramMetrics.horizontalSpacing, y: top), + size: size + ) + return [Node(id: node.id, node: node, rect: rect, parentId: parentId)] + } + + var childPositions: [Node] = [] + var currentX = xOffset + for child in node.children { + let childNodes = position( + child, depth: depth + 1, xOffset: currentX, parentId: node.id, rowOffsets: rowOffsets + ) + currentX += subtreeWidth(childNodes) + QueryPlanDiagramMetrics.horizontalSpacing + childPositions.append(contentsOf: childNodes) + } + + let firstChildX = childPositions.first { $0.parentId == node.id }?.rect.midX ?? xOffset + let lastChildX = childPositions.last { $0.parentId == node.id }?.rect.midX ?? xOffset + let centerX = (firstChildX + lastChildX) / 2 + + let rect = CGRect( + origin: CGPoint(x: centerX - QueryPlanDiagramMetrics.nodeWidth / 2, y: top), + size: size + ) + return [Node(id: node.id, node: node, rect: rect, parentId: parentId)] + childPositions + } + + private static func subtreeWidth(_ nodes: [Node]) -> CGFloat { + guard let minX = nodes.map({ $0.rect.minX }).min(), + let maxX = nodes.map({ $0.rect.maxX }).max() + else { return QueryPlanDiagramMetrics.nodeWidth } + return maxX - minX + } + + private static func canvasSize(of nodes: [Node]) -> CGSize { + let maxX = nodes.map { $0.rect.maxX }.max() ?? 400 + let maxY = nodes.map { $0.rect.maxY }.max() ?? 300 + return CGSize( + width: maxX + QueryPlanDiagramMetrics.horizontalSpacing * 2, + height: maxY + QueryPlanDiagramMetrics.verticalSpacing * 2 + ) + } +} diff --git a/TablePro/Views/QueryPlan/QueryPlanDiagramView.swift b/TablePro/Views/QueryPlan/QueryPlanDiagramView.swift index 201d7b4fd..46167c19b 100644 --- a/TablePro/Views/QueryPlan/QueryPlanDiagramView.swift +++ b/TablePro/Views/QueryPlan/QueryPlanDiagramView.swift @@ -2,251 +2,120 @@ // QueryPlanDiagramView.swift // TablePro // -// Canvas-based EXPLAIN plan diagram with boxes and arrows. +// EXPLAIN plan diagram: boxes and arrows on an AppKit-magnified canvas, so pan and zoom +// behave the way every other document surface on macOS does. // import SwiftUI -// MARK: - Layout Constants - -private enum PlanLayout { - static let nodeWidth: CGFloat = 200 - static let nodeMinHeight: CGFloat = 50 - static let horizontalSpacing: CGFloat = 24 - static let verticalSpacing: CGFloat = 40 - static let nodePadding: CGFloat = 8 - static let cornerRadius: CGFloat = 6 - static let arrowHeadSize: CGFloat = 6 -} - -// MARK: - Positioned Node - -private struct PositionedNode: Identifiable { - let id: UUID - let node: QueryPlanNode - let rect: CGRect - let parentId: UUID? -} +struct QueryPlanDiagramView: View { + @Binding var selectedNodeId: UUID? -// MARK: - Diagram View + @State private var viewport = DiagramViewportController() -struct QueryPlanDiagramView: View { - let plan: QueryPlan + /// Derived from the plan on every update, so a second EXPLAIN in the same tab redraws + /// instead of keeping the layout the first one produced. + private let layout: QueryPlanDiagramLayout - @State private var magnification: CGFloat = 1.0 - @State private var selectedNode: SelectedNodeID? - @State private var positioned: [PositionedNode] = [] - @State private var canvasSize = CGSize(width: 400, height: 300) + init(plan: QueryPlan, selectedNodeId: Binding) { + layout = QueryPlanDiagramLayout(root: plan.rootNode) + _selectedNodeId = selectedNodeId + } var body: some View { ZStack(alignment: .bottomTrailing) { - ScrollView([.horizontal, .vertical]) { - ZStack(alignment: .topLeading) { - Canvas { context, _ in - drawArrows(context: context, nodes: positioned) - } - .frame(width: canvasSize.width, height: canvasSize.height) + MagnifiableCanvasView( + viewport: viewport, + contentSize: layout.canvasSize, + accessibilityIdentifier: "query-plan-diagram" + ) { + canvas + } - ForEach(positioned) { pos in - diagramNode(pos) - .popover(isPresented: popoverBinding(for: pos.id)) { - if let node = findNode(pos.id, in: plan.rootNode) { - nodeDetailPopover(node) - } - } - .position(x: pos.rect.midX, y: pos.rect.midY) - } + DiagramZoomToolbar(viewport: viewport) { + Divider().frame(height: 16) + Button { + DiagramImageExporter.export( + exportCanvas, + defaultFileName: "query-plan.png", + title: String(localized: "Export Query Plan") + ) + } label: { + Image(systemName: "square.and.arrow.up") } - .frame(width: canvasSize.width, height: canvasSize.height) - .scaleEffect(magnification) - .frame( - width: canvasSize.width * magnification, - height: canvasSize.height * magnification, - alignment: .topLeading - ) + .accessibilityLabel(String(localized: "Export Plan as Image")) + .help(String(localized: "Export Plan as Image")) } - - zoomControls - .padding(12) - } - .task(id: plan.rawText) { - let nodes = layoutNodes(plan.rootNode, depth: 0, xOffset: 0, parentId: nil) - positioned = nodes - canvasSize = calculateCanvasSize(nodes) + .padding(12) } + .onCopyCommand { DiagramImageExporter.copyItemProviders(of: exportCanvas) } } - // MARK: - Node - - private func diagramNode(_ pos: PositionedNode) -> some View { - let node = pos.node - let isSelected = selectedNode?.id == pos.id + // MARK: - Canvas - return VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 4) { - Text(node.operation) - .font(.system(.callout, weight: .semibold)) - .lineLimit(1) - if let joinType = node.properties["Join Type"] { - Text(joinType) - .font(.caption2) - .foregroundStyle(.secondary) - } - } - - if let relation = node.relation { - Text(relation) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - } + private var canvas: some View { + ZStack(alignment: .topLeading) { + Canvas { context, _ in drawArrows(context: context) } + .frame(width: layout.canvasSize.width, height: layout.canvasSize.height) + .accessibilityHidden(true) - HStack(spacing: 6) { - if let startup = node.estimatedStartupCost, let total = node.estimatedTotalCost { - Text(String(format: "%.1f..%.1f", startup, total)) - .font(.system(.caption2, design: .monospaced)) - .foregroundStyle(.tertiary) - } - if let rows = node.estimatedRows { - Text("\(rows) rows") - .font(.system(.caption2, design: .monospaced)) - .foregroundStyle(.tertiary) + ForEach(layout.nodes) { positioned in + QueryPlanDiagramNodeView( + node: positioned.node, + isSelected: selectedNodeId == positioned.id + ) + .onTapGesture { selectedNodeId = positioned.id } + .contextMenu { nodeContextMenu(for: positioned.node) } + .popover(isPresented: detailBinding(for: positioned.id)) { + QueryPlanDetailPane(node: positioned.node) + .frame(minWidth: 260, maxWidth: 420) + .padding(4) } - } - - if let time = node.actualTotalTime { - Text(String(format: "%.3fms", time)) - .font(.system(.caption2, design: .monospaced)) - .foregroundStyle(.quaternary) + .position(x: positioned.rect.midX, y: positioned.rect.midY) } } - .padding(PlanLayout.nodePadding) - .frame(width: PlanLayout.nodeWidth, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: PlanLayout.cornerRadius) - .fill(nodeColor(fraction: node.costFraction).opacity(0.12)) - ) - .overlay( - RoundedRectangle(cornerRadius: PlanLayout.cornerRadius) - .stroke( - isSelected ? Color.accentColor : nodeColor(fraction: node.costFraction), - lineWidth: isSelected ? 2 : 1 - ) - ) - .onTapGesture { selectedNode = SelectedNodeID(id: pos.id) } - .accessibilityLabel("\(node.operation)\(node.relation.map { " on \($0)" } ?? "")") + .frame(width: layout.canvasSize.width, height: layout.canvasSize.height) } - // MARK: - Zoom + /// A non-interactive copy at natural scale, so an export never captures the current zoom, + /// scroll offset or selection. + private var exportCanvas: some View { + ZStack(alignment: .topLeading) { + Canvas { context, _ in drawArrows(context: context) } + .frame(width: layout.canvasSize.width, height: layout.canvasSize.height) - private var zoomControls: some View { - HStack(spacing: 4) { - Button { magnification = max(0.25, magnification - 0.25) } label: { - Image(systemName: "minus.magnifyingglass") - .frame(width: 24, height: 24) + ForEach(layout.nodes) { positioned in + QueryPlanDiagramNodeView(node: positioned.node, isSelected: false) + .position(x: positioned.rect.midX, y: positioned.rect.midY) } - .accessibilityLabel(String(localized: "Zoom out")) - .help(String(localized: "Zoom out")) - - Text("\(Int(magnification * 100))%") - .font(.caption) - .foregroundStyle(.secondary) - .frame(width: 36) - - Button { magnification = min(3.0, magnification + 0.25) } label: { - Image(systemName: "plus.magnifyingglass") - .frame(width: 24, height: 24) - } - .accessibilityLabel(String(localized: "Zoom in")) - .help(String(localized: "Zoom in")) } - .buttonStyle(.bordered) - .controlSize(.small) + .frame(width: layout.canvasSize.width, height: layout.canvasSize.height) + .background(Color(nsColor: .controlBackgroundColor)) } - // MARK: - Color - - private func nodeColor(fraction: Double) -> Color { - if fraction > 0.5 { return .red } - if fraction > 0.2 { return .orange } - if fraction > 0.05 { return .yellow } - return .green - } - - // MARK: - Layout - - private func layoutNodes( - _ node: QueryPlanNode, depth: Int, xOffset: CGFloat, parentId: UUID? - ) -> [PositionedNode] { - let nodeHeight = estimateNodeHeight(node) - var result: [PositionedNode] = [] - - if node.children.isEmpty { - let rect = CGRect( - x: xOffset + PlanLayout.horizontalSpacing, - y: CGFloat(depth) * (nodeHeight + PlanLayout.verticalSpacing) + PlanLayout.verticalSpacing, - width: PlanLayout.nodeWidth, - height: nodeHeight - ) - result.append(PositionedNode(id: node.id, node: node, rect: rect, parentId: parentId)) - } else { - var childPositions: [PositionedNode] = [] - var currentX = xOffset - - for child in node.children { - let childNodes = layoutNodes(child, depth: depth + 1, xOffset: currentX, parentId: node.id) - let childWidth = subtreeWidth(childNodes) - currentX += childWidth + PlanLayout.horizontalSpacing - childPositions.append(contentsOf: childNodes) - } - - let firstChildX = childPositions.first { $0.parentId == node.id }?.rect.midX ?? xOffset - let lastChildX = childPositions.last { $0.parentId == node.id }?.rect.midX ?? xOffset - let centerX = (firstChildX + lastChildX) / 2 - - let rect = CGRect( - x: centerX - PlanLayout.nodeWidth / 2, - y: CGFloat(depth) * (nodeHeight + PlanLayout.verticalSpacing) + PlanLayout.verticalSpacing, - width: PlanLayout.nodeWidth, - height: nodeHeight - ) - result.append(PositionedNode(id: node.id, node: node, rect: rect, parentId: parentId)) - result.append(contentsOf: childPositions) + @ViewBuilder + private func nodeContextMenu(for node: QueryPlanNode) -> some View { + Button(String(localized: "Copy Operation")) { + ClipboardService.shared.writeText(node.operation) + } + Button(String(localized: "Copy Node Details")) { + ClipboardService.shared.writeText(QueryPlanNodeSummary.text(for: node)) } - - return result - } - - private func estimateNodeHeight(_ node: QueryPlanNode) -> CGFloat { - var h: CGFloat = 18 - if node.relation != nil { h += 14 } - if node.estimatedTotalCost != nil || node.estimatedRows != nil { h += 12 } - if node.actualTotalTime != nil { h += 12 } - return max(PlanLayout.nodeMinHeight, h + PlanLayout.nodePadding * 2) - } - - private func subtreeWidth(_ nodes: [PositionedNode]) -> CGFloat { - guard let minX = nodes.map({ $0.rect.minX }).min(), - let maxX = nodes.map({ $0.rect.maxX }).max() - else { return PlanLayout.nodeWidth } - return maxX - minX } - private func calculateCanvasSize(_ nodes: [PositionedNode]) -> CGSize { - let maxX = nodes.map { $0.rect.maxX }.max() ?? 400 - let maxY = nodes.map { $0.rect.maxY }.max() ?? 300 - return CGSize( - width: maxX + PlanLayout.horizontalSpacing * 2, - height: maxY + PlanLayout.verticalSpacing * 2 + private func detailBinding(for nodeId: UUID) -> Binding { + Binding( + get: { selectedNodeId == nodeId }, + set: { if !$0 { selectedNodeId = nil } } ) } // MARK: - Arrows - private func drawArrows(context: GraphicsContext, nodes: [PositionedNode]) { - let nodeMap = Dictionary(uniqueKeysWithValues: nodes.map { ($0.id, $0) }) + private func drawArrows(context: GraphicsContext) { + let nodeMap = Dictionary(uniqueKeysWithValues: layout.nodes.map { ($0.id, $0) }) - for node in nodes { + for node in layout.nodes { guard let parentId = node.parentId, let parent = nodeMap[parentId] else { continue } let start = CGPoint(x: parent.rect.midX, y: parent.rect.maxY) @@ -259,91 +128,83 @@ struct QueryPlanDiagramView: View { context.stroke(path, with: .color(.secondary.opacity(0.4)), lineWidth: 1) var arrow = Path() - let s = PlanLayout.arrowHeadSize + let size = QueryPlanDiagramMetrics.arrowHeadSize arrow.move(to: end) - arrow.addLine(to: CGPoint(x: end.x - s, y: end.y - s)) - arrow.addLine(to: CGPoint(x: end.x + s, y: end.y - s)) + arrow.addLine(to: CGPoint(x: end.x - size, y: end.y - size)) + arrow.addLine(to: CGPoint(x: end.x + size, y: end.y - size)) arrow.closeSubpath() context.fill(arrow, with: .color(.secondary.opacity(0.4))) } } +} - // MARK: - Popover - - private static let hiddenKeys: Set = [ - "Parallel Aware", "Async Capable", "Disabled", "Inner Unique", - ] +// MARK: - Node - private func nodeDetailPopover(_ node: QueryPlanNode) -> some View { - let filtered = node.properties - .filter { !Self.hiddenKeys.contains($0.key) } - .filter { $0.value != "false" && $0.value != "0" } - .sorted { $0.key < $1.key } +private struct QueryPlanDiagramNodeView: View { + @Environment(\.accessibilityDifferentiateWithoutColor) private var differentiateWithoutColor - return VStack(alignment: .leading, spacing: 6) { - Text(node.operation) - .font(.headline) + let node: QueryPlanNode + let isSelected: Bool - if let relation = node.relation { detailRow("Table", relation) } - if let s = node.estimatedStartupCost, let t = node.estimatedTotalCost { - detailRow("Cost", String(format: "%.2f..%.2f", s, t)) + var body: some View { + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 4) { + Image(systemName: node.severity.symbolName) + .font(.system(size: 7)) + .foregroundStyle(tint) + Text(node.operation) + .font(.system(.callout, weight: .semibold)) + .lineLimit(1) + if let joinType = node.properties["Join Type"] { + Text(joinType) + .font(.caption2) + .foregroundStyle(.secondary) + } } - if let rows = node.estimatedRows { detailRow("Rows", "\(rows)") } - if let width = node.estimatedWidth, width > 0 { detailRow("Width", "\(width)") } - if let time = node.actualTotalTime { - Divider() - detailRow("Actual Time", String(format: "%.3fms", time)) - if let rows = node.actualRows { detailRow("Actual Rows", "\(rows)") } - if let loops = node.actualLoops, loops > 1 { detailRow("Loops", "\(loops)") } + if let relation = node.relation { + Text(relation) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) } - if !filtered.isEmpty { - Divider() - ForEach(filtered, id: \.key) { key, value in - detailRow(key, value) + HStack(spacing: 6) { + if let cost = node.costRangeText(fractionDigits: 1) { + Text(cost) + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.tertiary) + } + if let rows = node.estimatedRows { + Text("\(rows) ^[rows](inflect: true)") + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.tertiary) } } - } - .padding() - .frame(minWidth: 240) - } - private func detailRow(_ label: String, _ value: String) -> some View { - HStack(alignment: .top) { - Text(label) - .font(.caption) - .foregroundStyle(.secondary) - .frame(width: 90, alignment: .trailing) - Text(value) - .font(.system(.caption, design: .monospaced)) - .textSelection(.enabled) + if let time = node.actualTotalTime { + Text(QueryPlanLabels.milliseconds(time)) + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.quaternary) + } } - } - - // MARK: - Popover Binding - - private func popoverBinding(for nodeId: UUID) -> Binding { - Binding( - get: { selectedNode?.id == nodeId }, - set: { if !$0 { selectedNode = nil } } + .padding(QueryPlanDiagramMetrics.nodePadding) + .frame(width: QueryPlanDiagramMetrics.nodeWidth, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: QueryPlanDiagramMetrics.cornerRadius) + .fill(tint.opacity(0.12)) ) + .overlay( + RoundedRectangle(cornerRadius: QueryPlanDiagramMetrics.cornerRadius) + .stroke(isSelected ? Color.accentColor : tint, lineWidth: isSelected ? 2 : 1) + ) + .contentShape(RoundedRectangle(cornerRadius: QueryPlanDiagramMetrics.cornerRadius)) + .accessibilityElement(children: .combine) + .accessibilityAddTraits(.isButton) + .accessibilityLabel(QueryPlanNodeSummary.accessibilityLabel(for: node)) } - // MARK: - Find Node - - private func findNode(_ id: UUID?, in node: QueryPlanNode) -> QueryPlanNode? { - guard let id else { return nil } - if node.id == id { return node } - for child in node.children { - if let found = findNode(id, in: child) { return found } - } - return nil + private var tint: Color { + node.severity.tint(differentiateWithoutColor: differentiateWithoutColor) } } - -// MARK: - Identifiable Wrapper - -private struct SelectedNodeID: Identifiable { - let id: UUID -} diff --git a/TablePro/Views/QueryPlan/QueryPlanOutlineCellViews.swift b/TablePro/Views/QueryPlan/QueryPlanOutlineCellViews.swift new file mode 100644 index 000000000..f214f3bb8 --- /dev/null +++ b/TablePro/Views/QueryPlan/QueryPlanOutlineCellViews.swift @@ -0,0 +1,69 @@ +// +// QueryPlanOutlineCellViews.swift +// TablePro +// +// Cell contents for the plan outline. Each cell carries its own column-scoped accessibility +// label, which is what VoiceOver expects from a real multi-column table. +// + +import SwiftUI + +struct QueryPlanOperationCellView: View { + let node: QueryPlanNode + let differentiateWithoutColor: Bool + + var body: some View { + HStack(spacing: 6) { + Image(systemName: node.severity.symbolName) + .font(.system(size: 8)) + .foregroundStyle(node.severity.tint(differentiateWithoutColor: differentiateWithoutColor)) + .accessibilityHidden(true) + + Text(node.operation) + .font(.system(.body, weight: .medium)) + .lineLimit(1) + + if let joinType = node.properties["Join Type"] { + Text("(\(joinType))") + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + + if let relation = node.relation { + Text(relation) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + + if let index = node.properties["Index Name"] { + Text("using \(index)") + .font(.caption) + .foregroundStyle(.tertiary) + .lineLimit(1) + } + } + + Spacer(minLength: 0) + } + .accessibilityElement(children: .ignore) + .accessibilityLabel(QueryPlanNodeSummary.accessibilityLabel(for: node)) + } +} + +struct QueryPlanMetricCellView: View { + let text: String? + let label: String + + var body: some View { + HStack { + Spacer(minLength: 0) + Text(text ?? "") + .font(.system(.callout, design: .monospaced)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + .accessibilityElement(children: .ignore) + .accessibilityLabel(text.map { "\(label) \($0)" } ?? label) + } +} diff --git a/TablePro/Views/QueryPlan/QueryPlanOutlineCoordinator.swift b/TablePro/Views/QueryPlan/QueryPlanOutlineCoordinator.swift new file mode 100644 index 000000000..feece8473 --- /dev/null +++ b/TablePro/Views/QueryPlan/QueryPlanOutlineCoordinator.swift @@ -0,0 +1,219 @@ +// +// QueryPlanOutlineCoordinator.swift +// TablePro +// +// Data source, delegate and menu for the plan outline. +// + +import AppKit +import SwiftUI + +@MainActor +final class QueryPlanOutlineCoordinator: NSObject, NSOutlineViewDataSource, NSOutlineViewDelegate { + var onSelect: (UUID?) -> Void = { _ in } + + weak var outlineView: NSOutlineView? + + private(set) var root: QueryPlanOutlineNode? + private var planId: UUID? + private var differentiateWithoutColor = false + + // MARK: - Content + + func update(plan: QueryPlan, differentiateWithoutColor: Bool) { + let colorChanged = self.differentiateWithoutColor != differentiateWithoutColor + self.differentiateWithoutColor = differentiateWithoutColor + + guard planId != plan.rootNode.id else { + if colorChanged { outlineView?.reloadData() } + return + } + + planId = plan.rootNode.id + root = QueryPlanOutlineNode(plan.rootNode) + + // The rebuilt tree is in parse order, so a sort indicator left over from the previous + // plan would advertise an order the rows are not in. + outlineView?.sortDescriptors = [] + outlineView?.reloadData() + expandAll() + selectRootRow() + } + + /// Only ever drives the selection forward. A nil never clears the row, because the parent's + /// binding starts nil and would otherwise wipe the root selection made on first load. + func select(nodeId: UUID?) { + guard let outlineView, let nodeId, let node = find(nodeId, in: root) else { return } + let row = outlineView.row(forItem: node) + guard row >= 0 else { return } + guard outlineView.selectedRow != row else { return } + outlineView.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false) + outlineView.scrollRowToVisible(row) + } + + func expandAll() { + guard let outlineView, let root else { return } + outlineView.expandItem(root, expandChildren: true) + } + + func collapseAll() { + guard let outlineView, let root else { return } + outlineView.collapseItem(root, collapseChildren: true) + } + + // MARK: - Columns + + func configureColumns(on outlineView: NSOutlineView) { + guard outlineView.tableColumns.isEmpty else { return } + + for column in QueryPlanOutlineColumn.allCases { + let tableColumn = NSTableColumn(identifier: NSUserInterfaceItemIdentifier(column.rawValue)) + tableColumn.title = column.title + tableColumn.width = column.width + tableColumn.minWidth = column.minimumWidth + tableColumn.sortDescriptorPrototype = NSSortDescriptor( + key: column.rawValue, + ascending: QueryPlanOutlineSort.defaultAscending(for: column) + ) + outlineView.addTableColumn(tableColumn) + } + outlineView.columnAutoresizingStyle = .lastColumnOnlyAutoresizingStyle + } + + // MARK: - Data source + + func outlineView(_ outlineView: NSOutlineView, numberOfChildrenOfItem item: Any?) -> Int { + children(of: item).count + } + + func outlineView(_ outlineView: NSOutlineView, child index: Int, ofItem item: Any?) -> Any { + children(of: item)[index] + } + + func outlineView(_ outlineView: NSOutlineView, isItemExpandable item: Any) -> Bool { + (item as? QueryPlanOutlineNode)?.isExpandable ?? false + } + + func outlineView(_ outlineView: NSOutlineView, sortDescriptorsDidChange oldDescriptors: [NSSortDescriptor]) { + guard let descriptor = outlineView.sortDescriptors.first, + let key = descriptor.key, + let column = QueryPlanOutlineColumn(rawValue: key), + let root + else { return } + + let selected = selectedNodeId + self.root = root.sorted( + by: QueryPlanOutlineSort.comparator(key: column, ascending: descriptor.ascending) + ) + outlineView.reloadData() + expandAll() + select(nodeId: selected) + } + + private func children(of item: Any?) -> [QueryPlanOutlineNode] { + guard let node = item as? QueryPlanOutlineNode else { + return root.map { [$0] } ?? [] + } + return node.children + } + + // MARK: - Delegate + + func outlineView( + _ outlineView: NSOutlineView, + viewFor tableColumn: NSTableColumn?, + item: Any + ) -> NSView? { + guard let node = item as? QueryPlanOutlineNode, + let identifier = tableColumn?.identifier, + let column = QueryPlanOutlineColumn(rawValue: identifier.rawValue) + else { return nil } + + let content: AnyView + switch column { + case .operation: + content = AnyView( + QueryPlanOperationCellView( + node: node.source, + differentiateWithoutColor: differentiateWithoutColor + ) + ) + case .cost: + content = AnyView( + QueryPlanMetricCellView( + text: node.source.costRangeText(fractionDigits: 2), + label: QueryPlanLabels.cost + ) + ) + case .rows: + content = AnyView( + QueryPlanMetricCellView( + text: node.source.estimatedRows.map { $0.formatted(.number.grouping(.automatic)) }, + label: QueryPlanLabels.rows + ) + ) + case .actualTime: + content = AnyView( + QueryPlanMetricCellView( + text: node.source.actualTotalTime.map(QueryPlanLabels.milliseconds), + label: QueryPlanLabels.actualTime + ) + ) + } + + return hostingCell(identifier: identifier, outlineView: outlineView, content: content) + } + + func outlineViewSelectionDidChange(_ notification: Notification) { + onSelect(selectedNodeId) + } + + var selectedNodeId: UUID? { + guard let outlineView, outlineView.selectedRow >= 0 else { return nil } + return (outlineView.item(atRow: outlineView.selectedRow) as? QueryPlanOutlineNode)?.source.id + } + + var clickedNode: QueryPlanNode? { + guard let outlineView else { return nil } + let row = outlineView.clickedRow >= 0 ? outlineView.clickedRow : outlineView.selectedRow + guard row >= 0 else { return nil } + return (outlineView.item(atRow: row) as? QueryPlanOutlineNode)?.source + } + + // MARK: - Private + + /// Publishing the root selection is deferred, because the first update runs while SwiftUI is + /// building the view and a binding written there is dropped. + private func selectRootRow() { + guard let outlineView, outlineView.numberOfRows > 0 else { return } + outlineView.selectRowIndexes(IndexSet(integer: 0), byExtendingSelection: false) + + let rootId = selectedNodeId + Task { @MainActor [weak self] in + self?.onSelect(rootId) + } + } + + private func find(_ id: UUID, in node: QueryPlanOutlineNode?) -> QueryPlanOutlineNode? { + guard let node else { return nil } + if node.source.id == id { return node } + for child in node.children { + if let found = find(id, in: child) { return found } + } + return nil + } + + private func hostingCell( + identifier: NSUserInterfaceItemIdentifier, + outlineView: NSOutlineView, + content: AnyView + ) -> NSView { + if let reused = outlineView.makeView(withIdentifier: identifier, owner: self) as? NSHostingView { + reused.rootView = content + return reused + } + let hosting = NSHostingView(rootView: content) + hosting.identifier = identifier + return hosting + } +} diff --git a/TablePro/Views/QueryPlan/QueryPlanOutlineNode.swift b/TablePro/Views/QueryPlan/QueryPlanOutlineNode.swift new file mode 100644 index 000000000..d86fe4807 --- /dev/null +++ b/TablePro/Views/QueryPlan/QueryPlanOutlineNode.swift @@ -0,0 +1,98 @@ +// +// QueryPlanOutlineNode.swift +// TablePro +// +// NSOutlineView tracks items by reference identity, so the value-typed plan tree is wrapped +// once per parse into objects the outline can hold on to. +// + +import Foundation + +@MainActor +final class QueryPlanOutlineNode: NSObject { + let source: QueryPlanNode + let children: [QueryPlanOutlineNode] + + init(_ source: QueryPlanNode) { + self.source = source + children = source.children.map(QueryPlanOutlineNode.init) + } + + var isExpandable: Bool { !children.isEmpty } + + /// Reorders siblings at every level. A plan tree cannot be flattened and sorted, because a + /// join's inputs are not interchangeable with a subtree at another depth, so the shape is + /// preserved and only the order within each parent changes. + func sorted(by comparator: (QueryPlanNode, QueryPlanNode) -> Bool) -> QueryPlanOutlineNode { + QueryPlanOutlineNode(sortedSource(by: comparator)) + } + + private func sortedSource(by comparator: (QueryPlanNode, QueryPlanNode) -> Bool) -> QueryPlanNode { + var copy = source + copy.children = source.children + .map { child in QueryPlanOutlineNode(child).sortedSource(by: comparator) } + .sorted(by: comparator) + return copy + } +} + +enum QueryPlanOutlineSort { + static func comparator( + key: QueryPlanOutlineColumn, + ascending: Bool + ) -> (QueryPlanNode, QueryPlanNode) -> Bool { + { lhs, rhs in + let result: Bool + switch key { + case .operation: + result = lhs.operation.localizedStandardCompare(rhs.operation) == .orderedAscending + case .cost: + result = (lhs.estimatedTotalCost ?? -1) < (rhs.estimatedTotalCost ?? -1) + case .rows: + result = (lhs.estimatedRows ?? -1) < (rhs.estimatedRows ?? -1) + case .actualTime: + result = (lhs.actualTotalTime ?? -1) < (rhs.actualTotalTime ?? -1) + } + return ascending ? result : !result + } + } + + /// Numbers read worst-first, names read A to Z. + static func defaultAscending(for column: QueryPlanOutlineColumn) -> Bool { + column == .operation + } +} + +enum QueryPlanOutlineColumn: String, CaseIterable { + case operation + case cost + case rows + case actualTime + + var title: String { + switch self { + case .operation: return QueryPlanLabels.operation + case .cost: return QueryPlanLabels.cost + case .rows: return QueryPlanLabels.rows + case .actualTime: return QueryPlanLabels.actualTime + } + } + + var width: CGFloat { + switch self { + case .operation: return 320 + case .cost: return 110 + case .rows: return 90 + case .actualTime: return 100 + } + } + + var minimumWidth: CGFloat { + switch self { + case .operation: return 160 + case .cost: return 80 + case .rows: return 70 + case .actualTime: return 80 + } + } +} diff --git a/TablePro/Views/QueryPlan/QueryPlanOutlineView.swift b/TablePro/Views/QueryPlan/QueryPlanOutlineView.swift new file mode 100644 index 000000000..d75f0cdd4 --- /dev/null +++ b/TablePro/Views/QueryPlan/QueryPlanOutlineView.swift @@ -0,0 +1,127 @@ +// +// QueryPlanOutlineView.swift +// TablePro +// +// The plan as a real outline: labelled, resizable, sortable columns, keyboard navigation, +// type-select and VoiceOver all come from NSOutlineView rather than being approximated. +// + +import AppKit +import SwiftUI + +struct QueryPlanOutlineView: NSViewRepresentable { + let plan: QueryPlan + @Binding var selectedNodeId: UUID? + + @Environment(\.accessibilityDifferentiateWithoutColor) private var differentiateWithoutColor + + func makeCoordinator() -> QueryPlanOutlineCoordinator { + QueryPlanOutlineCoordinator() + } + + func makeNSView(context: Context) -> NSScrollView { + let outlineView = QueryPlanOutline() + outlineView.dataSource = context.coordinator + outlineView.delegate = context.coordinator + outlineView.style = .fullWidth + outlineView.rowSizeStyle = .custom + outlineView.rowHeight = 24 + outlineView.indentationPerLevel = 14 + outlineView.allowsMultipleSelection = false + outlineView.allowsEmptySelection = true + outlineView.usesAlternatingRowBackgroundColors = true + outlineView.headerView = NSTableHeaderView() + outlineView.allowsColumnResizing = true + outlineView.autosaveName = "com.TablePro.queryPlanOutline" + outlineView.autosaveTableColumns = true + + // Every EXPLAIN run mints fresh node identities, so persisted expansion would key off + // items that no longer exist. + outlineView.autosaveExpandedItems = false + outlineView.setAccessibilityIdentifier("query-plan-outline") + + context.coordinator.configureColumns(on: outlineView) + outlineView.outlineTableColumn = outlineView.tableColumns.first + context.coordinator.outlineView = outlineView + outlineView.coordinator = context.coordinator + outlineView.menu = makeMenu(coordinator: context.coordinator) + + context.coordinator.onSelect = { selectedNodeId = $0 } + context.coordinator.update(plan: plan, differentiateWithoutColor: differentiateWithoutColor) + + let scrollView = NSScrollView() + scrollView.documentView = outlineView + scrollView.hasVerticalScroller = true + scrollView.hasHorizontalScroller = true + scrollView.autohidesScrollers = true + scrollView.drawsBackground = false + return scrollView + } + + func updateNSView(_ nsView: NSScrollView, context: Context) { + context.coordinator.onSelect = { selectedNodeId = $0 } + context.coordinator.update(plan: plan, differentiateWithoutColor: differentiateWithoutColor) + context.coordinator.select(nodeId: selectedNodeId) + } + + private func makeMenu(coordinator: QueryPlanOutlineCoordinator) -> NSMenu { + let menu = NSMenu() + menu.delegate = coordinator + return menu + } +} + +/// Routes the standard Copy command through the responder chain, so Cmd+C and Edit > Copy both +/// reach the selected step instead of needing a hardcoded key handler. +@MainActor +final class QueryPlanOutline: NSOutlineView { + weak var coordinator: QueryPlanOutlineCoordinator? + + @objc func copy(_ sender: Any?) { + guard let node = coordinator?.clickedNode else { return } + ClipboardService.shared.writeText(QueryPlanNodeSummary.text(for: node)) + } +} + +extension QueryPlanOutlineCoordinator: NSMenuDelegate { + func menuNeedsUpdate(_ menu: NSMenu) { + menu.removeAllItems() + guard clickedNode != nil else { return } + + menu.addItem( + withTitle: String(localized: "Copy Node Details"), action: #selector(copyDetails), keyEquivalent: "" + ) + menu.addItem( + withTitle: String(localized: "Copy Operation"), action: #selector(copyOperation), keyEquivalent: "" + ) + menu.addItem(.separator()) + menu.addItem( + withTitle: String(localized: "Expand All"), action: #selector(expandAllItems), keyEquivalent: "" + ) + menu.addItem( + withTitle: String(localized: "Collapse All"), action: #selector(collapseAllItems), keyEquivalent: "" + ) + + for item in menu.items where item.action != nil { + item.target = self + } + } + + @objc private func copyDetails() { + guard let node = clickedNode else { return } + ClipboardService.shared.writeText(QueryPlanNodeSummary.text(for: node)) + } + + @objc private func copyOperation() { + guard let node = clickedNode else { return } + ClipboardService.shared.writeText(node.operation) + } + + @objc private func expandAllItems() { + expandAll() + } + + @objc private func collapseAllItems() { + collapseAll() + } +} diff --git a/TablePro/Views/QueryPlan/QueryPlanResultView.swift b/TablePro/Views/QueryPlan/QueryPlanResultView.swift new file mode 100644 index 000000000..16467288b --- /dev/null +++ b/TablePro/Views/QueryPlan/QueryPlanResultView.swift @@ -0,0 +1,238 @@ +// +// QueryPlanResultView.swift +// TablePro +// +// One EXPLAIN result, as a diagram, an outline, or the raw text the database returned. +// + +import SwiftUI + +enum QueryPlanViewMode: String, CaseIterable, Identifiable { + case diagram + case tree + case raw + + var id: String { rawValue } + + var title: String { + switch self { + case .diagram: return String(localized: "Diagram") + case .tree: return String(localized: "Tree") + case .raw: return String(localized: "Raw") + } + } +} + +/// What the pane can actually show for this result, resolved once so the view never has to +/// guess whether a plan is missing because the driver returned nothing or because parsing failed. +enum QueryPlanPresentation { + case empty + case parsed(QueryPlan) + case rawOnly(String) + + enum Kind: Equatable { + case empty + case parsed + case rawOnly + } + + static func resolve(plan: QueryPlan?, rawText: String) -> QueryPlanPresentation { + if let plan { return .parsed(plan) } + let trimmed = rawText.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? .empty : .rawOnly(trimmed) + } + + var kind: Kind { + switch self { + case .empty: return .empty + case .parsed: return .parsed + case .rawOnly: return .rawOnly + } + } + + var plan: QueryPlan? { + guard case .parsed(let plan) = self else { return nil } + return plan + } + + var rawText: String? { + guard case .rawOnly(let text) = self else { return nil } + return text + } +} + +struct QueryPlanResultView: View { + let rawText: String + let executionTime: TimeInterval? + let plan: QueryPlan? + + @AppStorage(PreferenceKeys.queryPlanRawFontSize.name) private var fontSize: Double = 13 + @State private var showCopyConfirmation = false + @State private var copyResetTask: Task? + @State private var viewMode: QueryPlanViewMode = .diagram + + /// Shared by the diagram and the outline, so switching view mode keeps the selected step. + @State private var selectedNodeId: UUID? + + private var presentation: QueryPlanPresentation { + QueryPlanPresentation.resolve(plan: plan, rawText: rawText) + } + + var body: some View { + VStack(spacing: 0) { + toolbar + Divider() + content + } + } + + @ViewBuilder + private var content: some View { + switch presentation { + case .empty: + EmptyStateView( + icon: "chart.bar.doc.horizontal", + title: String(localized: "No Plan Available"), + description: String(localized: "This database did not return a query plan for the statement.") + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + + case .rawOnly(let text): + VStack(spacing: 0) { + unparsedBanner + DDLTextView(ddl: text, fontSize: $fontSize) + } + + case .parsed(let plan): + switch viewMode { + case .diagram: + QueryPlanDiagramView(plan: plan, selectedNodeId: $selectedNodeId) + case .tree: + QueryPlanTreeView(plan: plan, selectedNodeId: $selectedNodeId) + case .raw: + DDLTextView(ddl: rawText, fontSize: $fontSize) + } + } + } + + private var unparsedBanner: some View { + HStack(spacing: 6) { + Image(systemName: "info.circle") + .foregroundStyle(.secondary) + Text(String(localized: "This plan could not be read as a tree. Showing the raw output.")) + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color(nsColor: .controlBackgroundColor)) + } + + // MARK: - Toolbar + + private var toolbar: some View { + HStack(spacing: 12) { + if presentation.plan != nil { + Picker("", selection: $viewMode) { + ForEach(QueryPlanViewMode.allCases) { mode in + Text(mode.title).tag(mode) + } + } + .pickerStyle(.segmented) + .controlSize(.small) + .frame(width: 240) + .labelsHidden() + .accessibilityIdentifier("query-plan-mode-picker") + } + + if viewMode == .raw || presentation.plan == nil { + fontSizeStepper + } + + timings + + Spacer() + + if showCopyConfirmation { + HStack { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + Text(String(localized: "Copied!")) + } + .transition(.opacity) + } + + Button(action: copyText) { + Label(String(localized: "Copy"), systemImage: "doc.on.doc") + } + .buttonStyle(.bordered) + .controlSize(.small) + .help(String(localized: "Copy EXPLAIN output to clipboard")) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Color(nsColor: .controlBackgroundColor)) + } + + private var fontSizeStepper: some View { + HStack(spacing: 4) { + Button { fontSize = max(10, fontSize - 1) } label: { + Image(systemName: "textformat.size.smaller") + .frame(width: 24, height: 24) + } + .accessibilityLabel(String(localized: "Decrease font size")) + Text("\(Int(fontSize))") + .font(.caption) + .foregroundStyle(.secondary) + .frame(width: 24) + Button { fontSize = min(24, fontSize + 1) } label: { + Image(systemName: "textformat.size.larger") + .frame(width: 24, height: 24) + } + .accessibilityLabel(String(localized: "Increase font size")) + } + .buttonStyle(.borderless) + } + + @ViewBuilder + private var timings: some View { + if let plan = presentation.plan { + if let planTime = plan.planningTime { + Text(String(format: String(localized: "Planning: %.3fms"), planTime)) + .font(.caption) + .foregroundStyle(.secondary) + } + if let execTime = plan.executionTime { + Text(String(format: String(localized: "Execution: %.3fms"), execTime)) + .font(.caption) + .foregroundStyle(.secondary) + } + } else if let executionTime { + Text(formattedDuration(executionTime)) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + private func copyText() { + ClipboardService.shared.writeText(rawText) + withAnimation { showCopyConfirmation = true } + copyResetTask?.cancel() + copyResetTask = Task { @MainActor in + try? await Task.sleep(for: .milliseconds(1_500)) + guard !Task.isCancelled else { return } + withAnimation { showCopyConfirmation = false } + } + } + + private func formattedDuration(_ duration: TimeInterval) -> String { + if duration < 0.001 { + return "<1ms" + } else if duration < 1.0 { + return String(format: "%.0fms", duration * 1_000) + } else { + return String(format: "%.2fs", duration) + } + } +} diff --git a/TablePro/Views/QueryPlan/QueryPlanSeverityStyle.swift b/TablePro/Views/QueryPlan/QueryPlanSeverityStyle.swift new file mode 100644 index 000000000..89902858c --- /dev/null +++ b/TablePro/Views/QueryPlan/QueryPlanSeverityStyle.swift @@ -0,0 +1,43 @@ +// +// QueryPlanSeverityStyle.swift +// TablePro +// +// How a severity is drawn. Cost is diagnostic rather than decorative, so the glyph and the +// cost text always carry the meaning and colour is only ever a second channel. +// + +import SwiftUI + +extension QueryPlanSeverity { + var color: Color { + switch self { + case .low: return .green + case .moderate: return .yellow + case .high: return .orange + case .critical: return .red + } + } + + /// Escalating shape, so severity reads without relying on hue. + var symbolName: String { + switch self { + case .low: return "circle.fill" + case .moderate: return "diamond.fill" + case .high: return "triangle.fill" + case .critical: return "exclamationmark.triangle.fill" + } + } + + var accessibilityLabel: String { + switch self { + case .low: return String(localized: "Low cost") + case .moderate: return String(localized: "Moderate cost") + case .high: return String(localized: "High cost") + case .critical: return String(localized: "Critical cost") + } + } + + func tint(differentiateWithoutColor: Bool) -> Color { + differentiateWithoutColor ? .secondary : color + } +} diff --git a/TablePro/Views/QueryPlan/QueryPlanTreeView.swift b/TablePro/Views/QueryPlan/QueryPlanTreeView.swift index ce972cd97..e20885898 100644 --- a/TablePro/Views/QueryPlan/QueryPlanTreeView.swift +++ b/TablePro/Views/QueryPlan/QueryPlanTreeView.swift @@ -2,199 +2,43 @@ // QueryPlanTreeView.swift // TablePro // -// Native SwiftUI tree view for EXPLAIN query plan visualization. -// Uses OutlineGroup for hierarchical display following macOS HIG. +// The plan as an outline over a resizable detail pane, matching the list-and-detail shape the +// rest of the app uses. // import SwiftUI struct QueryPlanTreeView: View { let plan: QueryPlan - - @State private var selection: UUID? + @Binding var selectedNodeId: UUID? var body: some View { - VStack(spacing: 0) { - List(selection: $selection) { - OutlineGroup( - [plan.rootNode], - id: \.id, - children: \.childrenOrNil - ) { node in - QueryPlanRowView(node: node) - } - } - .listStyle(.inset(alternatesRowBackgrounds: true)) - - if let selectedNode = findNode(selection, in: plan.rootNode) { - Divider() - QueryPlanDetailView(node: selectedNode) - .frame(height: 180) - } + AutosavingSplitView( + autosaveName: "com.TablePro.queryPlanTreeSplit", + isVertical: false, + primaryMinimum: 160, + secondaryMinimum: 120 + ) { + QueryPlanOutlineView(plan: plan, selectedNodeId: $selectedNodeId) + } secondary: { + QueryPlanDetailPane(node: selectedNode) } } - // MARK: - Find Node + private var selectedNode: QueryPlanNode? { + guard let selectedNodeId else { return nil } + return QueryPlanTreeView.find(selectedNodeId, in: plan.rootNode) + } - private func findNode(_ id: UUID?, in node: QueryPlanNode) -> QueryPlanNode? { - guard let id else { return nil } + static func find(_ id: UUID, in node: QueryPlanNode) -> QueryPlanNode? { if node.id == id { return node } for child in node.children { - if let found = findNode(id, in: child) { return found } + if let found = find(id, in: child) { return found } } return nil } } -// MARK: - Row View - -private struct QueryPlanRowView: View { - let node: QueryPlanNode - - var body: some View { - HStack(spacing: 8) { - Circle() - .fill(costColor) - .frame(width: 8, height: 8) - .accessibilityHidden(true) - - VStack(alignment: .leading, spacing: 1) { - HStack(spacing: 4) { - Text(node.operation) - .font(.system(.body, weight: .medium)) - if let joinType = node.properties["Join Type"] { - Text("(\(joinType))") - .font(.caption) - .foregroundStyle(.secondary) - } - } - - if let relation = node.relation { - HStack(spacing: 4) { - Text(relation) - .font(.caption) - .foregroundStyle(.secondary) - if let index = node.properties["Index Name"] { - Text("using \(index)") - .font(.caption) - .foregroundStyle(.tertiary) - } - } - } - } - - Spacer(minLength: 16) - - if let startup = node.estimatedStartupCost, let total = node.estimatedTotalCost { - Text(String(format: "%.2f..%.2f", startup, total)) - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(.secondary) - .frame(width: 110, alignment: .trailing) - } - - if let rows = node.estimatedRows { - Text("\(rows.formatted(.number.grouping(.automatic))) rows") - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(.secondary) - .frame(width: 80, alignment: .trailing) - } - - // Actual time (EXPLAIN ANALYZE) - if let time = node.actualTotalTime { - Text(String(format: "%.3fms", time)) - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(.tertiary) - .frame(width: 80, alignment: .trailing) - } - } - .padding(.vertical, 2) - } - - private var costColor: Color { - if node.costFraction > 0.5 { return .red } - if node.costFraction > 0.2 { return .orange } - if node.costFraction > 0.05 { return .yellow } - return .green - } -} - -// MARK: - Detail View - -private struct QueryPlanDetailView: View { - let node: QueryPlanNode - - /// Properties to hide (boolean flags and zero-value noise from PostgreSQL EXPLAIN). - private static let hiddenKeys: Set = [ - "Parallel Aware", "Async Capable", "Disabled", "Inner Unique", - ] - - private var filteredProperties: [(key: String, value: String)] { - node.properties - .filter { !Self.hiddenKeys.contains($0.key) } - .filter { $0.value != "false" && $0.value != "0" } - .sorted { $0.key < $1.key } - } - - var body: some View { - ScrollView(.horizontal, showsIndicators: false) { - ScrollView(.vertical) { - HStack(alignment: .top, spacing: 24) { - VStack(alignment: .leading, spacing: 4) { - Text(node.operation) - .font(.caption.weight(.semibold)) - if let relation = node.relation { detailRow("Table", relation) } - if let s = node.estimatedStartupCost, let t = node.estimatedTotalCost { - detailRow("Cost", String(format: "%.2f..%.2f", s, t)) - } - if let rows = node.estimatedRows { detailRow("Rows", "\(rows)") } - if let width = node.estimatedWidth, width > 0 { detailRow("Width", "\(width)") } - } - - // Actuals (EXPLAIN ANALYZE) - if node.actualTotalTime != nil { - VStack(alignment: .leading, spacing: 4) { - Text("Actual") - .font(.caption.weight(.semibold)) - if let time = node.actualTotalTime { - detailRow("Time", String(format: "%.3fms", time)) - } - if let rows = node.actualRows { detailRow("Rows", "\(rows)") } - if let loops = node.actualLoops, loops > 1 { detailRow("Loops", "\(loops)") } - } - } - - if !filteredProperties.isEmpty { - VStack(alignment: .leading, spacing: 4) { - Text("Details") - .font(.caption.weight(.semibold)) - ForEach(filteredProperties, id: \.key) { key, value in - detailRow(key, value) - } - } - } - - Spacer() - } - .padding(12) - } - } - .background(Color(nsColor: .controlBackgroundColor)) - } - - private func detailRow(_ label: String, _ value: String) -> some View { - HStack(alignment: .top, spacing: 6) { - Text(label) - .font(.caption) - .foregroundStyle(.secondary) - Text(value) - .font(.system(.caption, design: .monospaced)) - .textSelection(.enabled) - } - } -} - -// MARK: - Children Helper - extension QueryPlanNode { var childrenOrNil: [QueryPlanNode]? { children.isEmpty ? nil : children diff --git a/TableProTests/Core/Plugins/ExplainVariantFormatTests.swift b/TableProTests/Core/Plugins/ExplainVariantFormatTests.swift new file mode 100644 index 000000000..2afcd10bd --- /dev/null +++ b/TableProTests/Core/Plugins/ExplainVariantFormatTests.swift @@ -0,0 +1,63 @@ +// +// ExplainVariantFormatTests.swift +// TableProTests +// +// Guards the additive contract of ExplainVariant.format: a plugin built against the older +// three-argument initializer must keep compiling and must report the documented default. +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@Suite("Explain Variant Format") +struct ExplainVariantFormatTests { + @Test("The legacy initializer still compiles and defaults to plain text") + func legacyInitializerDefaultsToPlainText() { + let variant = ExplainVariant(id: "explain", label: "EXPLAIN", sqlPrefix: "EXPLAIN") + + #expect(variant.id == "explain") + #expect(variant.label == "EXPLAIN") + #expect(variant.sqlPrefix == "EXPLAIN") + #expect(variant.format == .plainText) + } + + @Test("The format initializer round-trips an explicit format") + func formatInitializerRoundTrips() { + let variant = ExplainVariant( + id: "explain-json", + label: "EXPLAIN (JSON)", + sqlPrefix: "EXPLAIN FORMAT=JSON", + format: .mysqlComposite + ) + + #expect(variant.format == .mysqlComposite) + #expect(variant.sqlPrefix == "EXPLAIN FORMAT=JSON") + } + + @Test("Both initializers agree on the fields they share") + func initializersAgreeOnSharedFields() { + let legacy = ExplainVariant(id: "plan", label: "Query Plan", sqlPrefix: "EXPLAIN QUERY PLAN") + let tagged = ExplainVariant( + id: "plan", + label: "Query Plan", + sqlPrefix: "EXPLAIN QUERY PLAN", + format: .sqliteQueryPlan + ) + + #expect(legacy.id == tagged.id) + #expect(legacy.label == tagged.label) + #expect(legacy.sqlPrefix == tagged.sqlPrefix) + #expect(legacy.format != tagged.format) + } + + @Test("An unknown format a future plugin names round-trips without a PluginKit change") + func unknownFormatRoundTrips() { + let future = ExplainPlanFormat(rawValue: "someFutureEngine") + let variant = ExplainVariant(id: "explain", label: "EXPLAIN", sqlPrefix: "EXPLAIN", format: future) + + #expect(variant.format.rawValue == "someFutureEngine") + #expect(variant.format != .plainText) + } +} diff --git a/TableProTests/Core/Services/Query/CockroachDBPlanParserTests.swift b/TableProTests/Core/Services/Query/CockroachDBPlanParserTests.swift index e99174430..bb898106e 100644 --- a/TableProTests/Core/Services/Query/CockroachDBPlanParserTests.swift +++ b/TableProTests/Core/Services/Query/CockroachDBPlanParserTests.swift @@ -7,6 +7,7 @@ import Foundation @testable import TablePro +import TableProPluginKit import Testing @Suite("CockroachDB Plan Parser") @@ -96,8 +97,9 @@ struct CockroachDBPlanParserTests { #expect(parser.parse(rawText: "distribution: local\nvectorized: true") == nil) } - @Test("Factory returns CockroachDB parser for .cockroachdb") - func factoryReturnsCockroachParser() { - #expect(QueryPlanParserFactory.parser(for: .cockroachdb) is CockroachDBPlanParser) + @Test("A CockroachDB plan resolves to the CockroachDB parser") + func registryReturnsCockroachParser() { + let format = ExplainFormatResolver.resolve(declared: .plainText, databaseType: .cockroachdb) + #expect(ExplainPlanParserRegistry.parser(for: format) is CockroachDBPlanParser) } } diff --git a/TableProTests/Core/Services/Query/ExplainPlanFormatResolutionTests.swift b/TableProTests/Core/Services/Query/ExplainPlanFormatResolutionTests.swift new file mode 100644 index 000000000..aa07025c8 --- /dev/null +++ b/TableProTests/Core/Services/Query/ExplainPlanFormatResolutionTests.swift @@ -0,0 +1,123 @@ +// +// ExplainPlanFormatResolutionTests.swift +// TableProTests +// +// Tests for resolving an EXPLAIN plan format and mapping it to a parser. +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@Suite("Explain Plan Format Resolution") +struct ExplainPlanFormatResolutionTests { + private let mysqlVariants = [ + ExplainVariant(id: "explain", label: "EXPLAIN", sqlPrefix: "EXPLAIN", format: .mysqlComposite), + ExplainVariant( + id: "json", label: "EXPLAIN (JSON)", sqlPrefix: "EXPLAIN FORMAT=JSON", format: .mysqlComposite + ), + ] + + private let sqliteVariants = [ + ExplainVariant( + id: "plan", label: "Query Plan", sqlPrefix: "EXPLAIN QUERY PLAN", format: .sqliteQueryPlan + ) + ] + + // MARK: - Registry + + @Test("Every known format maps to its parser") + func mapsFormatsToParsers() { + #expect(ExplainPlanParserRegistry.parser(for: .postgresJson) is PostgreSQLPlanParser) + #expect(ExplainPlanParserRegistry.parser(for: .mysqlComposite) is MySQLPlanParser) + #expect(ExplainPlanParserRegistry.parser(for: .sqliteQueryPlan) is SQLitePlanParser) + #expect(ExplainPlanParserRegistry.parser(for: .cockroachText) is CockroachDBPlanParser) + #expect(ExplainPlanParserRegistry.parser(for: .indentedText) is IndentedTextPlanParser) + } + + @Test("Plain text and an unknown format have no parser") + func degradesUnknownFormats() { + #expect(ExplainPlanParserRegistry.parser(for: .plainText) == nil) + #expect(ExplainPlanParserRegistry.parser(for: ExplainPlanFormat(rawValue: "future")) == nil) + } + + // MARK: - Curated defaults + + @Test("A database type the app knows resolves a format even when the variant declares none") + func fallsBackToCuratedDefault() { + #expect(ExplainFormatResolver.resolve(declared: .plainText, databaseType: .pglite) == .postgresJson) + #expect(ExplainFormatResolver.resolve(declared: .plainText, databaseType: .redshift) == .postgresJson) + #expect(ExplainFormatResolver.resolve(declared: .plainText, databaseType: .cloudflareD1) == .sqliteQueryPlan) + #expect(ExplainFormatResolver.resolve(declared: .plainText, databaseType: .libsql) == .sqliteQueryPlan) + #expect(ExplainFormatResolver.resolve(declared: .plainText, databaseType: .turso) == .sqliteQueryPlan) + #expect(ExplainFormatResolver.resolve(declared: .plainText, databaseType: .cockroachdb) == .cockroachText) + #expect(ExplainFormatResolver.resolve(declared: .plainText, databaseType: .duckdb) == .indentedText) + } + + @Test("A declared format always wins over the curated default") + func declaredFormatWins() { + #expect(ExplainFormatResolver.resolve(declared: .indentedText, databaseType: .postgresql) == .indentedText) + } + + @Test("An unknown database type stays plain text") + func unknownDatabaseTypeStaysPlainText() { + let unknown = DatabaseType(rawValue: "SomeFutureEngine") + #expect(ExplainFormatResolver.resolve(declared: .plainText, databaseType: unknown) == .plainText) + } + + // MARK: - Statement matching + + @Test("The longest matching prefix wins") + func longestPrefixWins() { + let matched = ExplainFormatResolver.matchingVariant( + sql: "EXPLAIN FORMAT=JSON SELECT 1", declaredVariants: mysqlVariants + ) + #expect(matched?.id == "json") + } + + @Test("Prefix matching ignores case and leading whitespace") + func matchesCaseInsensitively() { + let matched = ExplainFormatResolver.matchingVariant( + sql: " explain query plan SELECT 1", declaredVariants: sqliteVariants + ) + #expect(matched?.id == "plan") + } + + @Test("A statement no variant declares has no match") + func rejectsUndeclaredStatement() { + #expect(ExplainFormatResolver.matchingVariant(sql: "ANALYZE TABLE users", declaredVariants: mysqlVariants) == nil) + #expect(ExplainFormatResolver.matchingVariant(sql: "SELECT 1", declaredVariants: mysqlVariants) == nil) + #expect(ExplainFormatResolver.matchingVariant(sql: "", declaredVariants: mysqlVariants) == nil) + } + + @Test("A typed statement with no declared match still resolves the database default") + func typedStatementFallsBackToDatabaseDefault() { + let format = ExplainFormatResolver.resolve( + sql: "EXPLAIN ANALYZE SELECT 1", databaseType: .mysql, declaredVariants: mysqlVariants + ) + #expect(format == .mysqlComposite) + } + + // MARK: - Flattening + + @Test("Single-column rows join with newlines") + func flattensSingleColumn() { + let rows: [[PluginCellValue]] = [[.text("a")], [.text("b")]] + #expect(ExplainPlanTextFlattener.flatten(rows: rows) == "a\nb") + } + + @Test("Multi-column rows join columns with tabs") + func flattensMultipleColumns() { + let rows: [[PluginCellValue]] = [ + [.text("1"), .text("0"), .text("0"), .text("SCAN users")], + [.text("2"), .text("1"), .text("0"), .text("SEARCH orders")], + ] + #expect(ExplainPlanTextFlattener.flatten(rows: rows) == "1\t0\t0\tSCAN users\n2\t1\t0\tSEARCH orders") + } + + @Test("No rows flatten to an empty string") + func flattensEmptyRows() { + #expect(ExplainPlanTextFlattener.flatten(rows: []).isEmpty) + } +} diff --git a/TableProTests/Core/Services/Query/ExplainResultRouterTests.swift b/TableProTests/Core/Services/Query/ExplainResultRouterTests.swift index b802d155c..a994a68df 100644 --- a/TableProTests/Core/Services/Query/ExplainResultRouterTests.swift +++ b/TableProTests/Core/Services/Query/ExplainResultRouterTests.swift @@ -4,37 +4,112 @@ // import Foundation +@testable import TablePro import TableProPluginKit import Testing -@testable import TablePro -@Suite("ExplainResultRouter planText") +@Suite("ExplainResultRouter") struct ExplainResultRouterTests { + private let sqliteVariants = [ + ExplainVariant( + id: "plan", label: "Query Plan", sqlPrefix: "EXPLAIN QUERY PLAN", format: .sqliteQueryPlan + ) + ] + + private let mysqlVariants = [ + ExplainVariant(id: "explain", label: "EXPLAIN", sqlPrefix: "EXPLAIN", format: .mysqlComposite) + ] + @Test("Joins single-column explain rows with newlines") func joinsSingleColumnRows() { let rows: [[PluginCellValue]] = [[.text("-> Limit: 5 row(s)")], [.text(" -> Sort")]] - let result = ExplainResultRouter.planText(sql: "EXPLAIN ANALYZE SELECT 1", columns: ["EXPLAIN"], rows: rows) - #expect(result == "-> Limit: 5 row(s)\n -> Sort") + let routed = ExplainResultRouter.route( + sql: "EXPLAIN ANALYZE SELECT 1", + columns: ["EXPLAIN"], + rows: rows, + databaseType: .mysql, + declaredVariants: mysqlVariants + ) + #expect(routed?.rawText == "-> Limit: 5 row(s)\n -> Sort") + } + + @Test("A multi-column plan the app can read routes to the viewer") + func acceptsParsableMultiColumn() { + let rows: [[PluginCellValue]] = [[.text("2"), .text("0"), .text("0"), .text("SCAN users")]] + let routed = ExplainResultRouter.route( + sql: "EXPLAIN QUERY PLAN SELECT 1", + columns: ["id", "parent", "notused", "detail"], + rows: rows, + databaseType: .sqlite, + declaredVariants: sqliteVariants + ) + #expect(routed?.rawText == "2\t0\t0\tSCAN users") + #expect(routed?.plan != nil) + } + + /// MySQL declares an `EXPLAIN` variant, so prefix matching alone would drag its tabular + /// EXPLAIN into the plan viewer. Requiring a parse keeps it in the grid. + @Test("MySQL's tabular EXPLAIN stays in the results grid") + func rejectsTabularMySQLExplain() { + let rows: [[PluginCellValue]] = [ + [.text("1"), .text("SIMPLE"), .text("users"), .text("ALL"), .text("10")] + ] + let routed = ExplainResultRouter.route( + sql: "EXPLAIN SELECT * FROM users", + columns: ["id", "select_type", "table", "type", "rows"], + rows: rows, + databaseType: .mysql, + declaredVariants: mysqlVariants + ) + #expect(routed == nil) } - @Test("Returns nil for multi-column explain results") - func rejectsMultiColumn() { - let rows: [[PluginCellValue]] = [[.text("1"), .text("SIMPLE")]] - let result = ExplainResultRouter.planText(sql: "EXPLAIN SELECT 1", columns: ["id", "select_type"], rows: rows) - #expect(result == nil) + @Test("A maintenance ANALYZE is not a plan") + func rejectsMaintenanceAnalyze() { + let rows: [[PluginCellValue]] = [[.text("db.users"), .text("analyze"), .text("status"), .text("OK")]] + let routed = ExplainResultRouter.route( + sql: "ANALYZE TABLE users", + columns: ["Table", "Op", "Msg_type", "Msg_text"], + rows: rows, + databaseType: .mysql, + declaredVariants: mysqlVariants + ) + #expect(routed == nil) } @Test("Returns nil for non-explain statements") func rejectsNonExplain() { let rows: [[PluginCellValue]] = [[.text("value")]] - let result = ExplainResultRouter.planText(sql: "SELECT col FROM t", columns: ["col"], rows: rows) - #expect(result == nil) + let routed = ExplainResultRouter.route( + sql: "SELECT col FROM t", + columns: ["col"], + rows: rows, + databaseType: .mysql, + declaredVariants: mysqlVariants + ) + #expect(routed == nil) } @Test("Returns nil when the plan text is empty") func rejectsEmptyPlan() { - #expect(ExplainResultRouter.planText(sql: "EXPLAIN SELECT 1", columns: ["EXPLAIN"], rows: []) == nil) + #expect( + ExplainResultRouter.route( + sql: "EXPLAIN SELECT 1", + columns: ["EXPLAIN"], + rows: [], + databaseType: .mysql, + declaredVariants: mysqlVariants + ) == nil + ) let blank: [[PluginCellValue]] = [[.null]] - #expect(ExplainResultRouter.planText(sql: "EXPLAIN SELECT 1", columns: ["EXPLAIN"], rows: blank) == nil) + #expect( + ExplainResultRouter.route( + sql: "EXPLAIN SELECT 1", + columns: ["EXPLAIN"], + rows: blank, + databaseType: .mysql, + declaredVariants: mysqlVariants + ) == nil + ) } } diff --git a/TableProTests/Core/Services/Query/IndentedTextPlanParserTests.swift b/TableProTests/Core/Services/Query/IndentedTextPlanParserTests.swift new file mode 100644 index 000000000..b49d96720 --- /dev/null +++ b/TableProTests/Core/Services/Query/IndentedTextPlanParserTests.swift @@ -0,0 +1,83 @@ +// +// IndentedTextPlanParserTests.swift +// TableProTests +// +// Tests for parsing indentation-based EXPLAIN output (ClickHouse, DuckDB) into a QueryPlan tree. +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("Indented Text Plan Parser") +struct IndentedTextPlanParserTests { + private let parser = IndentedTextPlanParser() + + @Test("Indentation becomes nesting") + func nestsByIndentation() throws { + let output = [ + "Expression ((Projection + Before ORDER BY))", + " Aggregating", + " ReadFromMergeTree (default.events)", + ].joined(separator: "\n") + + let plan = try #require(parser.parse(rawText: output)) + + #expect(plan.rootNode.operation == "Expression ((Projection + Before ORDER BY))") + #expect(plan.rootNode.children.count == 1) + #expect(plan.rootNode.children[0].operation == "Aggregating") + #expect(plan.rootNode.children[0].children[0].operation == "ReadFromMergeTree (default.events)") + } + + @Test("Lines at the same indentation stay siblings") + func keepsSameIndentationFlat() throws { + let output = [ + "Union", + " Expression", + " Expression", + ].joined(separator: "\n") + + let plan = try #require(parser.parse(rawText: output)) + + #expect(plan.rootNode.children.count == 2) + #expect(plan.rootNode.children.allSatisfy { $0.children.isEmpty }) + } + + @Test("Several roots are wrapped in one synthetic node") + func wrapsMultipleRoots() throws { + let plan = try #require(parser.parse(rawText: "Projection\nAggregate")) + + #expect(plan.rootNode.operation == "Query Plan") + #expect(plan.rootNode.children.count == 2) + } + + @Test("Dedenting closes the deeper level") + func dedentClosesLevel() throws { + let output = [ + "Sort", + " Filter", + " Scan", + " Limit", + ].joined(separator: "\n") + + let plan = try #require(parser.parse(rawText: output)) + + #expect(plan.rootNode.children.count == 2) + #expect(plan.rootNode.children[0].operation == "Filter") + #expect(plan.rootNode.children[0].children.count == 1) + #expect(plan.rootNode.children[1].operation == "Limit") + } + + @Test("A single line is the whole plan") + func parsesSingleLine() throws { + let plan = try #require(parser.parse(rawText: "ReadFromStorage (SystemNumbers)")) + #expect(plan.rootNode.operation == "ReadFromStorage (SystemNumbers)") + #expect(plan.rootNode.children.isEmpty) + } + + @Test("Empty input has no plan") + func rejectsEmptyInput() { + #expect(parser.parse(rawText: "") == nil) + #expect(parser.parse(rawText: "\n\n") == nil) + } +} diff --git a/TableProTests/Core/Services/Query/MySQLPlanParserTests.swift b/TableProTests/Core/Services/Query/MySQLPlanParserTests.swift new file mode 100644 index 000000000..5c88e77a9 --- /dev/null +++ b/TableProTests/Core/Services/Query/MySQLPlanParserTests.swift @@ -0,0 +1,211 @@ +// +// MySQLPlanParserTests.swift +// TableProTests +// +// Tests for parsing MySQL EXPLAIN JSON and TREE output into a QueryPlan tree. +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@Suite("MySQL Plan Parser") +struct MySQLPlanParserTests { + private let parser = MySQLPlanParser() + + private let analyzeOutput = [ + "-> Inner hash join (t2.c2 = t1.c1) (cost=3.5 rows=5) (actual time=0.121..0.131 rows=1 loops=1)", + " -> Table scan on t2 (cost=0.07 rows=5) (actual time=0.0126..0.0221 rows=5 loops=1)", + " -> Hash", + " -> Table scan on t1 (cost=0.75 rows=5) (actual time=0.0372..0.0534 rows=5 loops=1)", + ].joined(separator: "\n") + + @Test("Parses TREE hierarchy and ANALYZE metrics") + func parsesTreeHierarchyAndMetrics() throws { + let plan = try #require(parser.parse(rawText: analyzeOutput)) + + #expect(plan.rootNode.operation == "Inner hash join (t2.c2 = t1.c1)") + #expect(plan.rootNode.estimatedTotalCost == 3.5) + #expect(plan.rootNode.estimatedRows == 5) + #expect(plan.rootNode.actualStartupTime == 0.121) + #expect(plan.rootNode.actualTotalTime == 0.131) + #expect(plan.rootNode.actualRows == 1) + #expect(plan.rootNode.actualLoops == 1) + #expect(plan.executionTime == 0.131) + #expect(plan.rootNode.children.count == 2) + + let tableScan = plan.rootNode.children[0] + #expect(tableScan.operation == "Table scan") + #expect(tableScan.relation == "t2") + + let hash = plan.rootNode.children[1] + #expect(hash.operation == "Hash") + #expect(hash.children.count == 1) + #expect(hash.children[0].relation == "t1") + } + + @Test("Extracts index details from access nodes") + func extractsIndexDetails() throws { + let output = """ + -> Index range scan on `orders archive` using `created_at_idx` over (created_at > 1) \ + (cost=12.5 rows=42) + """ + + let plan = try #require(parser.parse(rawText: output)) + #expect(plan.rootNode.operation == "Index range scan") + #expect(plan.rootNode.relation == "`orders archive`") + #expect(plan.rootNode.properties["Index Name"] == "`created_at_idx`") + #expect(plan.rootNode.properties["Details"]?.contains("created_at > 1") == true) + } + + @Test("Accepts engineering notation and averages across loops") + func acceptsEngineeringNotation() throws { + let output = """ + -> Table scan on huge_table (cost=1.23e+9 rows=934e+6) \ + (actual time=934e-6..1.2e+6 rows=1.23e+3 loops=2) + """ + + let plan = try #require(parser.parse(rawText: output)) + #expect(plan.rootNode.estimatedTotalCost == 1.23e+9) + #expect(plan.rootNode.estimatedRows == 934_000_000) + #expect(plan.rootNode.actualStartupTime == 934e-6) + #expect(plan.rootNode.actualTotalTime == 1.2e+6) + #expect(plan.rootNode.actualRows == 1_230) + #expect(plan.rootNode.actualLoops == 2) + #expect(plan.executionTime == 2.4e+6) + } + + @Test("Joins wrapped metric lines") + func joinsWrappedMetrics() throws { + let output = [ + "-> Filter: (users.active = true)", + " (cost=4.2 rows=8)", + " (actual time=0.01..0.03 rows=7 loops=1)", + " -> Table scan on users (cost=3 rows=10)", + ].joined(separator: "\r\n") + + let plan = try #require(parser.parse(rawText: output)) + #expect(plan.rootNode.estimatedTotalCost == 4.2) + #expect(plan.rootNode.actualRows == 7) + #expect(plan.rootNode.children.first?.relation == "users") + } + + @Test("Keeps nodes that were never executed") + func keepsNeverExecutedNodes() throws { + let output = [ + "-> Nested loop inner join (cost=5 rows=1)", + " -> Table scan on users (cost=2 rows=1)", + " -> Index lookup on orders using user_id_idx (cost=3 rows=1) (never executed)", + ].joined(separator: "\n") + + let plan = try #require(parser.parse(rawText: output)) + let skipped = plan.rootNode.children[1] + #expect(skipped.operation == "Index lookup") + #expect(skipped.actualTotalTime == nil) + #expect(skipped.properties["Execution"] == "Never executed") + } + + @Test("Falls back to the existing JSON parser") + func parsesJSONPlan() throws { + let output = """ + { + "query_block": { + "cost_info": { "query_cost": "1.25" }, + "table": { + "table_name": "users", + "access_type": "ALL", + "rows_examined_per_scan": 10 + } + } + } + """ + + let plan = try #require(parser.parse(rawText: output)) + #expect(plan.rootNode.operation == "Query Block") + #expect(plan.rootNode.estimatedTotalCost == 1.25) + #expect(plan.rootNode.children.first?.relation == "users") + #expect(plan.rootNode.children.first?.estimatedRows == 10) + } + + @Test("Wraps multiple top-level iterators in one synthetic root") + func wrapsMultipleRoots() throws { + let output = [ + "-> Table scan on users (cost=1 rows=1)", + "-> Table scan on roles (cost=3 rows=1)", + ].joined(separator: "\n") + + let plan = try #require(parser.parse(rawText: output)) + #expect(plan.rootNode.operation == "Query Plan") + #expect(plan.rootNode.children.count == 2) + #expect(plan.rootNode.estimatedTotalCost == 4) + #expect(plan.rootNode.costFraction == 0) + #expect(plan.rootNode.children[0].costFraction == 0.25) + #expect(plan.rootNode.children[1].costFraction == 0.75) + } + + @Test("Reports the estimated cost even without a startup cost") + func reportsCostWithoutStartupCost() throws { + let plan = try #require(parser.parse(rawText: "-> Table scan on users (cost=3.5 rows=5)")) + + #expect(plan.rootNode.estimatedStartupCost == nil) + #expect(plan.rootNode.costRangeText(fractionDigits: 1) == "3.5") + #expect(plan.rootNode.costRangeText(fractionDigits: 2) == "3.50") + } + + @Test("Keeps node text that the query's own literals split across lines") + func keepsSplitNodeText() throws { + let output = [ + "-> Filter: (users.name = 'a", + "b') (cost=4.2 rows=8)", + " -> Table scan on users (cost=3 rows=10)", + ].joined(separator: "\n") + + let plan = try #require(parser.parse(rawText: output)) + #expect(plan.rootNode.operation == "Filter: (users.name = 'a b')") + #expect(plan.rootNode.estimatedTotalCost == 4.2) + #expect(plan.rootNode.estimatedRows == 8) + #expect(plan.rootNode.children.first?.relation == "users") + } + + @Test("Reads the index name when the relation is padded with extra spaces") + func readsIndexNameAfterPaddedRelation() throws { + let output = "-> Index lookup on orders using customer_id_idx (cost=2 rows=3)" + + let plan = try #require(parser.parse(rawText: output)) + #expect(plan.rootNode.operation == "Index lookup") + #expect(plan.rootNode.relation == "orders") + #expect(plan.rootNode.properties["Index Name"] == "customer_id_idx") + } + + @Test("Rejects oversized JSON before it reaches JSONSerialization") + func rejectsOversizedJSON() { + let oversizedJson = "{\"query_block\": {\"comment\": \"" + + String(repeating: "x", count: 2_000_001) + + "\"}}" + #expect(parser.parse(rawText: oversizedJson) == nil) + } + + @Test("Rejects malformed, oversized, and excessively deep input") + func rejectsUnsafeInput() { + #expect(parser.parse(rawText: "") == nil) + #expect(parser.parse(rawText: "not a TREE plan") == nil) + #expect(parser.parse(rawText: String(repeating: "x", count: 2_000_001)) == nil) + + let overflowingTime = "-> Scan (actual time=1..1e308 rows=1 loops=1e18)" + #expect(parser.parse(rawText: overflowingTime)?.executionTime == nil) + + let excessiveDepth = (0..<130) + .map { String(repeating: " ", count: $0) + "-> Node \($0)" } + .joined(separator: "\n") + #expect(parser.parse(rawText: excessiveDepth) == nil) + } + + @Test("MySQL and MariaDB resolve to the composite parser") + func registryUsesCompositeParser() { + for databaseType in [DatabaseType.mysql, .mariadb] { + let format = ExplainFormatResolver.resolve(declared: .plainText, databaseType: databaseType) + #expect(ExplainPlanParserRegistry.parser(for: format) is MySQLPlanParser) + } + } +} diff --git a/TableProTests/Core/Services/Query/PostgreSQLPlanParserTests.swift b/TableProTests/Core/Services/Query/PostgreSQLPlanParserTests.swift new file mode 100644 index 000000000..57a6bc13d --- /dev/null +++ b/TableProTests/Core/Services/Query/PostgreSQLPlanParserTests.swift @@ -0,0 +1,136 @@ +// +// PostgreSQLPlanParserTests.swift +// TableProTests +// +// Tests for parsing PostgreSQL EXPLAIN (FORMAT JSON) output into a QueryPlan tree. +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("PostgreSQL Plan Parser") +struct PostgreSQLPlanParserTests { + private let parser = PostgreSQLPlanParser() + + private let estimatedPlan = """ + [ + { + "Plan": { + "Node Type": "Hash Join", + "Join Type": "Inner", + "Startup Cost": 1.09, + "Total Cost": 40.35, + "Plan Rows": 220, + "Plan Width": 68, + "Plans": [ + { + "Node Type": "Seq Scan", + "Relation Name": "orders", + "Schema": "public", + "Alias": "o", + "Startup Cost": 0.00, + "Total Cost": 30.20, + "Plan Rows": 200, + "Plan Width": 36, + "Filter": "(status = 'paid'::text)" + }, + { + "Node Type": "Hash", + "Startup Cost": 1.04, + "Total Cost": 1.04, + "Plan Rows": 4, + "Plan Width": 32 + } + ] + } + } + ] + """ + + @Test("Parses the node tree, costs and relations") + func parsesEstimatedPlan() throws { + let plan = try #require(parser.parse(rawText: estimatedPlan)) + + #expect(plan.rootNode.operation == "Hash Join") + #expect(plan.rootNode.estimatedStartupCost == 1.09) + #expect(plan.rootNode.estimatedTotalCost == 40.35) + #expect(plan.rootNode.estimatedRows == 220) + #expect(plan.rootNode.estimatedWidth == 68) + #expect(plan.rootNode.children.count == 2) + + let seqScan = plan.rootNode.children[0] + #expect(seqScan.operation == "Seq Scan") + #expect(seqScan.relation == "orders") + #expect(seqScan.schema == "public") + #expect(seqScan.alias == "o") + #expect(seqScan.costRangeText(fractionDigits: 2) == "0.00..30.20") + } + + @Test("Keeps unrecognised keys as node properties") + func keepsExtraKeysAsProperties() throws { + let plan = try #require(parser.parse(rawText: estimatedPlan)) + + #expect(plan.rootNode.properties["Join Type"] == "Inner") + #expect(plan.rootNode.children[0].properties["Filter"] == "(status = 'paid'::text)") + #expect(plan.rootNode.properties["Node Type"] == nil) + #expect(plan.rootNode.properties["Total Cost"] == nil) + } + + @Test("Cost fractions are relative to the root total") + func computesCostFractions() throws { + let plan = try #require(parser.parse(rawText: estimatedPlan)) + + #expect(plan.rootNode.costFraction > 0) + #expect(plan.rootNode.children.allSatisfy { $0.costFraction >= 0 }) + #expect(plan.rootNode.children[0].costFraction > plan.rootNode.children[1].costFraction) + } + + @Test("Reads planning and execution time from an ANALYZE plan") + func parsesAnalyzeTimings() throws { + let analyzePlan = """ + [ + { + "Plan": { + "Node Type": "Seq Scan", + "Relation Name": "users", + "Startup Cost": 0.00, + "Total Cost": 12.50, + "Plan Rows": 250, + "Plan Width": 40, + "Actual Startup Time": 0.015, + "Actual Total Time": 0.132, + "Actual Rows": 250, + "Actual Loops": 1 + }, + "Planning Time": 0.183, + "Execution Time": 0.201 + } + ] + """ + + let plan = try #require(parser.parse(rawText: analyzePlan)) + + #expect(plan.planningTime == 0.183) + #expect(plan.executionTime == 0.201) + #expect(plan.rootNode.actualStartupTime == 0.015) + #expect(plan.rootNode.actualTotalTime == 0.132) + #expect(plan.rootNode.actualRows == 250) + #expect(plan.rootNode.actualLoops == 1) + } + + @Test("Rejects malformed and non-plan input") + func rejectsMalformedInput() { + #expect(parser.parse(rawText: "") == nil) + #expect(parser.parse(rawText: "not json") == nil) + #expect(parser.parse(rawText: "{}") == nil) + #expect(parser.parse(rawText: "[]") == nil) + #expect(parser.parse(rawText: "[{\"NotAPlan\": {}}]") == nil) + } + + @Test("A node with no type is labelled rather than dropped") + func labelsUnknownNodeType() throws { + let plan = try #require(parser.parse(rawText: "[{\"Plan\": {\"Total Cost\": 1.0}}]")) + #expect(plan.rootNode.operation == "Unknown") + } +} diff --git a/TableProTests/Core/Services/Query/SQLitePlanParserTests.swift b/TableProTests/Core/Services/Query/SQLitePlanParserTests.swift new file mode 100644 index 000000000..9a5e67990 --- /dev/null +++ b/TableProTests/Core/Services/Query/SQLitePlanParserTests.swift @@ -0,0 +1,85 @@ +// +// SQLitePlanParserTests.swift +// TableProTests +// +// Tests for parsing SQLite EXPLAIN QUERY PLAN rows into a QueryPlan tree. Shared by the +// SQLite, Cloudflare D1 and libSQL drivers, which all return the same four columns. +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("SQLite Plan Parser") +struct SQLitePlanParserTests { + private let parser = SQLitePlanParser() + + @Test("Builds a tree from the id and parent columns") + func buildsTreeFromParentIds() throws { + let output = [ + "2\t0\t0\tSCAN users", + "4\t2\t0\tSEARCH orders USING INDEX idx_customer", + ].joined(separator: "\n") + + let plan = try #require(parser.parse(rawText: output)) + + #expect(plan.rootNode.operation == "SCAN users") + #expect(plan.rootNode.children.count == 1) + #expect(plan.rootNode.children[0].operation == "SEARCH orders USING INDEX idx_customer") + } + + @Test("Several children under one parent stay siblings") + func keepsSiblingsFlat() throws { + let output = [ + "1\t0\t0\tCOMPOUND QUERY", + "2\t1\t0\tLEFT-MOST SUBQUERY", + "3\t1\t0\tUNION ALL", + ].joined(separator: "\n") + + let plan = try #require(parser.parse(rawText: output)) + + #expect(plan.rootNode.operation == "COMPOUND QUERY") + #expect(plan.rootNode.children.count == 2) + #expect(plan.rootNode.children.allSatisfy { $0.children.isEmpty }) + } + + @Test("Several roots are wrapped in one synthetic node") + func wrapsMultipleRoots() throws { + let output = [ + "1\t0\t0\tSCAN users", + "2\t0\t0\tSCAN roles", + ].joined(separator: "\n") + + let plan = try #require(parser.parse(rawText: output)) + + #expect(plan.rootNode.operation == "Query Plan") + #expect(plan.rootNode.children.count == 2) + } + + @Test("Nesting goes deeper than one level") + func nestsDeeply() throws { + let output = [ + "1\t0\t0\tCO-ROUTINE subquery", + "2\t1\t0\tSCAN t1", + "3\t2\t0\tUSE TEMP B-TREE FOR ORDER BY", + ].joined(separator: "\n") + + let plan = try #require(parser.parse(rawText: output)) + + #expect(plan.rootNode.children.count == 1) + #expect(plan.rootNode.children[0].children.count == 1) + #expect(plan.rootNode.children[0].children[0].operation == "USE TEMP B-TREE FOR ORDER BY") + } + + @Test("A line that is not four columns becomes a flat detail node") + func fallsBackForMalformedLines() throws { + let plan = try #require(parser.parse(rawText: "SCAN users without columns")) + #expect(plan.rootNode.operation == "SCAN users without columns") + } + + @Test("Empty input has no plan") + func rejectsEmptyInput() { + #expect(parser.parse(rawText: "") == nil) + #expect(parser.parse(rawText: "\n\n") == nil) + } +} diff --git a/TableProTests/Models/ERDiagram/ERDiagramDragTests.swift b/TableProTests/Models/ERDiagram/ERDiagramDragTests.swift new file mode 100644 index 000000000..2aedc3aec --- /dev/null +++ b/TableProTests/Models/ERDiagram/ERDiagramDragTests.swift @@ -0,0 +1,137 @@ +// +// ERDiagramDragTests.swift +// TableProTests +// +// AppKit owns pan and zoom, so every point the view hands the view model is already in +// document space. These pin the hit testing, the node drag and the persisted coordinates. +// + +import CoreGraphics +import Foundation +@testable import TablePro +import Testing + +@Suite("ER diagram dragging") +@MainActor +struct ERDiagramDragTests { + private func makeViewModel() -> ERDiagramViewModel { + ERDiagramViewModel(connectionId: UUID(), databaseName: "app", schemaKey: "app.default") + } + + private func placeNode(in viewModel: ERDiagramViewModel, at position: CGPoint) -> UUID { + let nodeId = UUID() + viewModel.setPositionOverride(nodeId: nodeId, position: position) + return nodeId + } + + @Test("A drag starting inside a node rect grabs that node") + func beginDragHitsNodeInDocumentSpace() { + let viewModel = makeViewModel() + let nodeId = placeNode(in: viewModel, at: CGPoint(x: 400, y: 300)) + + viewModel.beginDrag(at: CGPoint(x: 400, y: 300)) + + #expect(viewModel.isDragging) + #expect(viewModel.draggingNodeId == nodeId) + } + + @Test("A point inside the node rect but off its centre still grabs the node") + func beginDragHitsNodeEdges() { + let viewModel = makeViewModel() + let nodeId = placeNode(in: viewModel, at: CGPoint(x: 400, y: 300)) + let rect = viewModel.nodeRect(for: nodeId) + + viewModel.beginDrag(at: CGPoint(x: rect.minX + 1, y: rect.minY + 1)) + + #expect(viewModel.draggingNodeId == nodeId) + } + + @Test("A drag starting on empty canvas pans instead of moving a node") + func beginDragOutsideEveryNodeStartsAPan() { + let viewModel = makeViewModel() + let nodeId = placeNode(in: viewModel, at: CGPoint(x: 400, y: 300)) + let rect = viewModel.nodeRect(for: nodeId) + + viewModel.beginDrag(at: CGPoint(x: rect.maxX + 200, y: rect.maxY + 200)) + + #expect(viewModel.isDragging) + #expect(viewModel.draggingNodeId == nil) + } + + @Test("A dragged node moves by the raw translation") + func updateDragMovesNodeByRawTranslation() { + let viewModel = makeViewModel() + let nodeId = placeNode(in: viewModel, at: CGPoint(x: 400, y: 300)) + + viewModel.beginDrag(at: CGPoint(x: 400, y: 300)) + viewModel.updateDrag( + translation: CGSize(width: 60, height: -25), + currentPoint: CGPoint(x: 460, y: 275) + ) + + #expect(viewModel.position(for: nodeId) == CGPoint(x: 460, y: 275)) + } + + @Test("A second update measures from the drag start, not the last position") + func updateDragIsAbsoluteFromDragStart() { + let viewModel = makeViewModel() + let nodeId = placeNode(in: viewModel, at: CGPoint(x: 400, y: 300)) + + viewModel.beginDrag(at: CGPoint(x: 400, y: 300)) + viewModel.updateDrag(translation: CGSize(width: 10, height: 10), currentPoint: CGPoint(x: 410, y: 310)) + viewModel.updateDrag(translation: CGSize(width: 30, height: 40), currentPoint: CGPoint(x: 430, y: 340)) + + #expect(viewModel.position(for: nodeId) == CGPoint(x: 430, y: 340)) + } + + @Test("A canvas pan leaves every node where it was") + func panDragDoesNotMoveNodes() { + let viewModel = makeViewModel() + let nodeId = placeNode(in: viewModel, at: CGPoint(x: 400, y: 300)) + let rect = viewModel.nodeRect(for: nodeId) + + viewModel.beginDrag(at: CGPoint(x: rect.maxX + 200, y: rect.maxY + 200)) + viewModel.updateDrag(translation: CGSize(width: 90, height: 90), currentPoint: .zero) + + #expect(viewModel.position(for: nodeId) == CGPoint(x: 400, y: 300)) + } + + @Test("Ending a drag clears the drag state") + func endDragClearsState() { + let viewModel = makeViewModel() + defer { ERDiagramPositionStorage.shared.clear(connectionId: viewModel.connectionId, schemaKey: "app.default") } + _ = placeNode(in: viewModel, at: CGPoint(x: 400, y: 300)) + + viewModel.beginDrag(at: CGPoint(x: 400, y: 300)) + viewModel.updateDrag(translation: CGSize(width: 5, height: 5), currentPoint: CGPoint(x: 405, y: 305)) + viewModel.endDrag() + + #expect(!viewModel.isDragging) + #expect(viewModel.draggingNodeId == nil) + } + + @Test("A position override round-trips and centres the node rect on it") + func positionOverrideRoundTrips() { + let viewModel = makeViewModel() + let position = CGPoint(x: 137.5, y: -42.25) + let nodeId = placeNode(in: viewModel, at: position) + + #expect(viewModel.position(for: nodeId) == position) + #expect(viewModel.nodeRect(for: nodeId).midX == position.x) + #expect(viewModel.nodeRect(for: nodeId).midY == position.y) + #expect(viewModel.nodeRect(for: nodeId).width == ERDiagramLayout.nodeWidth) + } + + @Test("A saved layout loads back at the same document coordinates") + func storedPositionsSurviveARoundTrip() { + let connectionId = UUID() + let schemaKey = "app.public" + let positions = ["orders": CGPoint(x: 512.5, y: -128.25), "customers": CGPoint(x: 0, y: 940)] + defer { ERDiagramPositionStorage.shared.clear(connectionId: connectionId, schemaKey: schemaKey) } + + ERDiagramPositionStorage.shared.save(positions, connectionId: connectionId, schemaKey: schemaKey) + let loaded = ERDiagramPositionStorage.shared.load(connectionId: connectionId, schemaKey: schemaKey) + + #expect(loaded == positions) + } +} diff --git a/TableProTests/Models/ERDiagram/ERDiagramScrollTranslatorTests.swift b/TableProTests/Models/ERDiagram/ERDiagramScrollTranslatorTests.swift deleted file mode 100644 index adf140438..000000000 --- a/TableProTests/Models/ERDiagram/ERDiagramScrollTranslatorTests.swift +++ /dev/null @@ -1,77 +0,0 @@ -import CoreGraphics -import Testing - -@testable import TablePro - -@Suite("ERDiagramScrollTranslator") -struct ERDiagramScrollTranslatorTests { - @Test("precise deltas pan point for point") - func preciseDeltasPanUnscaled() { - let action = ERDiagramScrollTranslator.action( - scrollingDeltaX: 12, - scrollingDeltaY: -8, - hasPreciseScrollingDeltas: true, - isZoomModifierActive: false, - currentOffset: CGPoint(x: 100, y: 50), - currentMagnification: 1.0 - ) - #expect(action == .pan(CGPoint(x: 112, y: 42))) - } - - @Test("line deltas pan with the wheel multiplier") - func lineDeltasPanScaled() { - let action = ERDiagramScrollTranslator.action( - scrollingDeltaX: 2, - scrollingDeltaY: 3, - hasPreciseScrollingDeltas: false, - isZoomModifierActive: false, - currentOffset: .zero, - currentMagnification: 1.0 - ) - #expect(action == .pan(CGPoint(x: 20, y: 30))) - } - - @Test("zoom modifier zooms from the vertical delta") - func zoomModifierZooms() { - let action = ERDiagramScrollTranslator.action( - scrollingDeltaX: 5, - scrollingDeltaY: 50, - hasPreciseScrollingDeltas: true, - isZoomModifierActive: true, - currentOffset: .zero, - currentMagnification: 1.0 - ) - guard case .zoom(let magnification) = action else { - Issue.record("expected zoom, got \(action)") - return - } - #expect(abs(magnification - 1.5) < 0.0001) - } - - @Test("zero deltas keep the offset") - func zeroDeltasKeepOffset() { - let offset = CGPoint(x: 33, y: -7) - let action = ERDiagramScrollTranslator.action( - scrollingDeltaX: 0, - scrollingDeltaY: 0, - hasPreciseScrollingDeltas: true, - isZoomModifierActive: false, - currentOffset: offset, - currentMagnification: 1.0 - ) - #expect(action == .pan(offset)) - } - - @Test("negative deltas invert both axes") - func negativeDeltasInvert() { - let action = ERDiagramScrollTranslator.action( - scrollingDeltaX: -4, - scrollingDeltaY: -6, - hasPreciseScrollingDeltas: false, - isZoomModifierActive: false, - currentOffset: CGPoint(x: 100, y: 100), - currentMagnification: 1.0 - ) - #expect(action == .pan(CGPoint(x: 60, y: 40))) - } -} diff --git a/TableProTests/Models/Query/ExplainRequestTests.swift b/TableProTests/Models/Query/ExplainRequestTests.swift new file mode 100644 index 000000000..a29fc81d0 --- /dev/null +++ b/TableProTests/Models/Query/ExplainRequestTests.swift @@ -0,0 +1,111 @@ +// +// ExplainRequestTests.swift +// TableProTests +// +// Tests for choosing which EXPLAIN variant runs and what format its output is read as. +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@Suite("Explain Request") +struct ExplainRequestTests { + private let postgresVariants = [ + ExplainVariant( + id: "explain", label: "EXPLAIN", sqlPrefix: "EXPLAIN (FORMAT JSON)", format: .postgresJson + ), + ExplainVariant( + id: "analyze", + label: "EXPLAIN ANALYZE", + sqlPrefix: "EXPLAIN (ANALYZE, FORMAT JSON)", + format: .postgresJson + ), + ] + + @Test("With no explicit choice the first declared variant runs") + func defaultsToFirstDeclaredVariant() throws { + let request = try #require( + ExplainRequest.make( + variant: nil, + declaredVariants: postgresVariants, + databaseType: .postgresql, + statement: "SELECT 1" + ) + ) + + #expect(request.sql == "EXPLAIN (FORMAT JSON) SELECT 1") + #expect(request.format == .postgresJson) + } + + @Test("An explicit variant overrides the default") + func explicitVariantWins() throws { + let request = try #require( + ExplainRequest.make( + variant: postgresVariants[1], + declaredVariants: postgresVariants, + databaseType: .postgresql, + statement: "SELECT 1" + ) + ) + + #expect(request.sql == "EXPLAIN (ANALYZE, FORMAT JSON) SELECT 1") + } + + @Test("A driver that declares no variants has no request to build") + func returnsNilWithoutVariants() { + #expect( + ExplainRequest.make( + variant: nil, declaredVariants: [], databaseType: .mongodb, statement: "SELECT 1" + ) == nil + ) + } + + @Test("A variant that names no format falls back to the database default") + func untaggedVariantUsesDatabaseDefault() throws { + let untagged = ExplainVariant(id: "plan", label: "Query Plan", sqlPrefix: "EXPLAIN QUERY PLAN") + let request = try #require( + ExplainRequest.make( + variant: untagged, + declaredVariants: [untagged], + databaseType: .cloudflareD1, + statement: "SELECT 1" + ) + ) + + #expect(request.format == .sqliteQueryPlan) + } + + @Test("A driver-built statement still resolves the database default format") + func driverBuiltUsesDatabaseDefault() { + let request = ExplainRequest.driverBuilt(sql: "EXPLAIN SELECT 1", databaseType: .duckdb) + + #expect(request.sql == "EXPLAIN SELECT 1") + #expect(request.format == .indentedText) + } + + @Test("A driver-built statement is marked so it keeps the ordinary result grid") + func driverBuiltIsFlagged() { + #expect(ExplainRequest.driverBuilt(sql: "DEBUG OBJECT key", databaseType: .redis).isDriverBuilt) + } + + @Test("A declared variant is not driver-built") + func declaredVariantIsNotDriverBuilt() throws { + let request = try #require( + ExplainRequest.make( + variant: nil, + declaredVariants: postgresVariants, + databaseType: .postgresql, + statement: "SELECT 1" + ) + ) + #expect(!request.isDriverBuilt) + } + + @Test("A driver-built statement on an unknown engine stays plain text") + func driverBuiltOnUnknownEngineStaysPlainText() { + let request = ExplainRequest.driverBuilt(sql: "DEBUG OBJECT key", databaseType: .redis) + #expect(request.format == .plainText) + } +} diff --git a/TableProTests/Models/Query/QueryPlanCostTests.swift b/TableProTests/Models/Query/QueryPlanCostTests.swift new file mode 100644 index 000000000..37ed755f6 --- /dev/null +++ b/TableProTests/Models/Query/QueryPlanCostTests.swift @@ -0,0 +1,112 @@ +// +// QueryPlanCostTests.swift +// TableProTests +// +// Tests for cost text and cost fractions on a parsed query plan. +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("Query Plan Cost") +struct QueryPlanCostTests { + private func node( + _ operation: String, + startupCost: Double? = nil, + totalCost: Double? = nil, + children: [QueryPlanNode] = [] + ) -> QueryPlanNode { + QueryPlanNode( + operation: operation, + relation: nil, + schema: nil, + alias: nil, + estimatedStartupCost: startupCost, + estimatedTotalCost: totalCost, + estimatedRows: nil, + estimatedWidth: nil, + actualStartupTime: nil, + actualTotalTime: nil, + actualRows: nil, + actualLoops: nil, + properties: [:], + children: children + ) + } + + @Test("A plan with only a total cost still shows it") + func showsTotalOnlyCost() { + let totalOnly = node("Table scan", totalCost: 3.5) + #expect(totalOnly.costRangeText(fractionDigits: 1) == "3.5") + #expect(totalOnly.costRangeText(fractionDigits: 2) == "3.50") + } + + @Test("A plan with both costs shows the range") + func showsCostRange() { + let range = node("Seq Scan", startupCost: 0.5, totalCost: 12.25) + #expect(range.costRangeText(fractionDigits: 1) == "0.5..12.2") + #expect(range.costRangeText(fractionDigits: 2) == "0.50..12.25") + } + + @Test("A plan with no cost shows nothing") + func hidesMissingCost() { + #expect(node("Hash").costRangeText(fractionDigits: 2) == nil) + } + + @Test("Cost fractions stay at zero when the root reports no cost") + func skipsFractionsWithoutRootCost() { + var plan = QueryPlan( + rootNode: node("Query Plan", children: [ + node("Table scan", totalCost: 1), + node("Table scan", totalCost: 1), + ]), + planningTime: nil, + executionTime: nil, + rawText: "" + ) + plan.computeCostFractions() + + #expect(plan.rootNode.children.allSatisfy { $0.costFraction == 0 }) + } + + @Test("Cost fractions are relative to the root total") + func dividesByRootCost() { + var plan = QueryPlan( + rootNode: node("Nested loop", totalCost: 10, children: [ + node("Table scan", totalCost: 6), + node("Table scan", totalCost: 2), + ]), + planningTime: nil, + executionTime: nil, + rawText: "" + ) + plan.computeCostFractions() + + #expect(plan.rootNode.costFraction == 0.2) + #expect(plan.rootNode.children[0].costFraction == 0.6) + #expect(plan.rootNode.children[1].costFraction == 0.2) + } + + @Test("A synthetic root totals the costs of the plans it wraps") + func sumsWrappedRootCosts() { + let roots = [node("Table scan", totalCost: 3), node("Table scan", totalCost: 1)] + let wrapped = QueryPlanTreeBuilder.root(from: roots) + + #expect(wrapped?.operation == "Query Plan") + #expect(wrapped?.estimatedTotalCost == 4) + #expect(wrapped?.exclusiveCost == 0) + } + + @Test("A synthetic root over costless plans reports no cost") + func leavesCostlessWrappedRootsAlone() { + let wrapped = QueryPlanTreeBuilder.root(from: [node("Scan"), node("Scan")]) + #expect(wrapped?.estimatedTotalCost == nil) + } + + @Test("A single root is returned unwrapped") + func returnsSingleRootUnwrapped() { + #expect(QueryPlanTreeBuilder.root(from: [node("Table scan")])?.operation == "Table scan") + #expect(QueryPlanTreeBuilder.root(from: []) == nil) + } +} diff --git a/TableProTests/Models/Query/QueryPlanNodeSummaryTests.swift b/TableProTests/Models/Query/QueryPlanNodeSummaryTests.swift new file mode 100644 index 000000000..89e61cad8 --- /dev/null +++ b/TableProTests/Models/Query/QueryPlanNodeSummaryTests.swift @@ -0,0 +1,83 @@ +// +// QueryPlanNodeSummaryTests.swift +// TableProTests +// +// Tests the plain-text and spoken renderings of a plan node. +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("Query Plan Node Summary") +struct QueryPlanNodeSummaryTests { + private func makeNode( + operation: String = "Seq Scan", + relation: String? = "orders", + startupCost: Double? = 0.5, + totalCost: Double? = 12.25, + rows: Int? = 1_204, + actualTime: Double? = nil, + properties: [String: String] = [:] + ) -> QueryPlanNode { + QueryPlanNode( + operation: operation, + relation: relation, + schema: nil, + alias: nil, + estimatedStartupCost: startupCost, + estimatedTotalCost: totalCost, + estimatedRows: rows, + estimatedWidth: nil, + actualStartupTime: nil, + actualTotalTime: actualTime, + actualRows: nil, + actualLoops: nil, + properties: properties, + children: [] + ) + } + + @Test("Copy text starts with the operation and lists what the node reports") + func buildsCopyText() { + let text = QueryPlanNodeSummary.text(for: makeNode(properties: ["Filter": "(id > 3)"])) + let lines = text.components(separatedBy: "\n") + + #expect(lines.first == "Seq Scan") + #expect(text.contains("orders")) + #expect(text.contains("0.50..12.25")) + #expect(text.contains("1204")) + #expect(text.contains("Filter: (id > 3)")) + } + + @Test("Copy text omits what the node does not report") + func omitsMissingValues() { + let text = QueryPlanNodeSummary.text( + for: makeNode(relation: nil, startupCost: nil, totalCost: nil, rows: nil) + ) + #expect(text == "Seq Scan") + } + + @Test("Hidden properties never reach the copy text") + func skipsHiddenProperties() { + let text = QueryPlanNodeSummary.text(for: makeNode(properties: ["Parallel Aware": "true"])) + #expect(!text.contains("Parallel Aware")) + } + + @Test("The spoken label names the operation, the relation and the severity") + func buildsAccessibilityLabel() { + let label = QueryPlanNodeSummary.accessibilityLabel(for: makeNode()) + + #expect(label.hasPrefix("Seq Scan")) + #expect(label.contains("orders")) + #expect(label.contains(QueryPlanSeverity.low.accessibilityLabel)) + } + + @Test("Actual timing appears in both renderings when present") + func includesActualTiming() { + let node = makeNode(actualTime: 3.25) + + #expect(QueryPlanNodeSummary.text(for: node).contains("3.250")) + #expect(QueryPlanNodeSummary.accessibilityLabel(for: node).contains("3.250")) + } +} diff --git a/TableProTests/Models/Query/QueryPlanSeverityTests.swift b/TableProTests/Models/Query/QueryPlanSeverityTests.swift new file mode 100644 index 000000000..21a72e495 --- /dev/null +++ b/TableProTests/Models/Query/QueryPlanSeverityTests.swift @@ -0,0 +1,70 @@ +// +// QueryPlanSeverityTests.swift +// TableProTests +// +// Tests for classifying a plan node's cost share into a severity. +// + +import Foundation +import SwiftUI +@testable import TablePro +import Testing + +@Suite("Query Plan Severity") +struct QueryPlanSeverityTests { + @Test("Each band maps to its severity") + func classifiesBands() { + #expect(QueryPlanSeverity.forCostFraction(0) == .low) + #expect(QueryPlanSeverity.forCostFraction(0.05) == .low) + #expect(QueryPlanSeverity.forCostFraction(0.06) == .moderate) + #expect(QueryPlanSeverity.forCostFraction(0.2) == .moderate) + #expect(QueryPlanSeverity.forCostFraction(0.21) == .high) + #expect(QueryPlanSeverity.forCostFraction(0.5) == .high) + #expect(QueryPlanSeverity.forCostFraction(0.51) == .critical) + #expect(QueryPlanSeverity.forCostFraction(1.0) == .critical) + } + + @Test("A non-finite or negative fraction is treated as cheap rather than trapping") + func handlesInvalidFractions() { + #expect(QueryPlanSeverity.forCostFraction(.nan) == .low) + #expect(QueryPlanSeverity.forCostFraction(.infinity) == .low) + #expect(QueryPlanSeverity.forCostFraction(-1) == .low) + } + + @Test("Every severity has a distinct glyph") + func glyphsAreDistinct() { + let symbols = Set(QueryPlanSeverity.allCases.map(\.symbolName)) + #expect(symbols.count == QueryPlanSeverity.allCases.count) + } + + @Test("Differentiate without colour drops the hue but keeps the severity") + func dropsHueWhenAsked() { + for severity in QueryPlanSeverity.allCases { + #expect(severity.tint(differentiateWithoutColor: true) == .secondary) + #expect(severity.tint(differentiateWithoutColor: false) == severity.color) + #expect(!severity.accessibilityLabel.isEmpty) + } + } + + @Test("Hidden property keys are filtered out of the visible set") + func filtersHiddenProperties() { + let node = QueryPlanNode( + operation: "Seq Scan", + relation: nil, schema: nil, alias: nil, + estimatedStartupCost: nil, estimatedTotalCost: nil, + estimatedRows: nil, estimatedWidth: nil, + actualStartupTime: nil, actualTotalTime: nil, + actualRows: nil, actualLoops: nil, + properties: [ + "Parallel Aware": "true", + "Filter": "(id > 3)", + "Rows Removed by Filter": "0", + "Sort Key": "id", + ], + children: [] + ) + + let visible = QueryPlanLabels.visibleProperties(of: node) + #expect(visible.map(\.key) == ["Filter", "Sort Key"]) + } +} diff --git a/TableProTests/Models/Query/ResultTabBarPolicyTests.swift b/TableProTests/Models/Query/ResultTabBarPolicyTests.swift index e60aa2c6e..de0208c1c 100644 --- a/TableProTests/Models/Query/ResultTabBarPolicyTests.swift +++ b/TableProTests/Models/Query/ResultTabBarPolicyTests.swift @@ -39,13 +39,19 @@ struct ResultTabBarPolicyTests { #expect(ResultTabBarPolicy.canPin(tabType: .query, display: display) == false) } - @Test("An explain result is not a result set, so nothing can be pinned") - func explainHasNothingToPin() { + @Test("An explain result is a result set, so it shows the strip and can be pinned") + @MainActor + func explainBehavesLikeAResultSet() { var display = Self.makeDisplay() - display.explainText = "Seq Scan on orders" + let plan = ExplainResultSetFactory.make( + rawText: "Seq Scan on orders", plan: nil, sql: "EXPLAIN SELECT 1", executionTime: 0.2 + ) + display.resultSets = [plan] + display.activeResultSetId = plan.id - #expect(ResultTabBarPolicy.showsTabBar(tabType: .query, display: display) == false) - #expect(ResultTabBarPolicy.canPin(tabType: .query, display: display) == false) + #expect(ResultTabBarPolicy.showsTabBar(tabType: .query, display: display)) + #expect(ResultTabBarPolicy.canPin(tabType: .query, display: display)) + #expect(display.activeExplainResult?.id == plan.id) } @Test("A tab with no results has no strip and nothing to pin") @@ -84,7 +90,7 @@ struct ResultTabBarPolicyTests { states.append(display) var explaining = display - explaining.explainText = "plan" + explaining.resultSets = [] states.append(explaining) } diff --git a/TableProTests/Views/Components/DiagramViewportControllerTests.swift b/TableProTests/Views/Components/DiagramViewportControllerTests.swift new file mode 100644 index 000000000..17069209a --- /dev/null +++ b/TableProTests/Views/Components/DiagramViewportControllerTests.swift @@ -0,0 +1,130 @@ +// +// DiagramViewportControllerTests.swift +// TableProTests +// +// Tests the viewport controller against a real NSScrollView, so the AppKit behaviour the +// diagrams now depend on (magnification bounds, KVO, fit) is pinned rather than assumed. +// + +import AppKit +@testable import TablePro +import Testing + +@Suite("Diagram Viewport Controller") +@MainActor +struct DiagramViewportControllerTests { + private func makeScrollView(content: CGSize, visible: CGSize) -> NSScrollView { + let scrollView = NSScrollView(frame: CGRect(origin: .zero, size: visible)) + scrollView.allowsMagnification = true + scrollView.minMagnification = DiagramZoom.minimum + scrollView.maxMagnification = DiagramZoom.maximum + + let documentView = NSView(frame: CGRect(origin: .zero, size: content)) + scrollView.documentView = documentView + scrollView.layoutSubtreeIfNeeded() + return scrollView + } + + private func makeAttached( + content: CGSize = CGSize(width: 1_000, height: 800), + visible: CGSize = CGSize(width: 500, height: 400) + ) -> (DiagramViewportController, NSScrollView) { + let scrollView = makeScrollView(content: content, visible: visible) + let viewport = DiagramViewportController() + viewport.attach(to: scrollView) + return (viewport, scrollView) + } + + @Test("A detached controller still clamps its own zoom") + func clampsWhileDetached() { + let viewport = DiagramViewportController() + #expect(viewport.magnification == 1.0) + + for _ in 0..<20 { viewport.zoomIn() } + #expect(viewport.magnification == DiagramZoom.maximum) + + for _ in 0..<40 { viewport.zoomOut() } + #expect(viewport.magnification == DiagramZoom.minimum) + + viewport.resetZoom() + #expect(viewport.magnification == 1.0) + } + + @Test("Zooming writes through to the scroll view") + func writesMagnificationToScrollView() { + let (viewport, scrollView) = makeAttached() + + viewport.zoomIn() + #expect(scrollView.magnification == 1.0 + DiagramZoom.step) + + viewport.resetZoom() + #expect(scrollView.magnification == 1.0) + } + + @Test("A magnification set on the scroll view is observed back") + func observesMagnificationChanges() { + let (viewport, scrollView) = makeAttached() + + scrollView.magnification = 2.0 + #expect(viewport.magnification == 2.0) + + scrollView.magnification = 0.5 + #expect(viewport.magnification == 0.5) + } + + @Test("AppKit enforces the magnification bounds it was given") + func scrollViewEnforcesBounds() { + let (_, scrollView) = makeAttached() + + scrollView.magnification = 99 + #expect(scrollView.magnification == DiagramZoom.maximum) + + scrollView.magnification = 0.001 + #expect(scrollView.magnification == DiagramZoom.minimum) + } + + @Test("Fit to window zooms out to show the whole diagram") + func fitZoomsOutToFitContent() { + let (viewport, scrollView) = makeAttached( + content: CGSize(width: 1_000, height: 800), visible: CGSize(width: 500, height: 400) + ) + + viewport.fitToWindow() + + #expect(scrollView.magnification < 1.0) + #expect(scrollView.magnification >= DiagramZoom.minimum) + } + + @Test("Fit to window never zooms past one hundred percent") + func fitNeverZoomsIn() { + let (viewport, scrollView) = makeAttached( + content: CGSize(width: 100, height: 80), visible: CGSize(width: 800, height: 600) + ) + + viewport.fitToWindow() + + #expect(scrollView.magnification == 1.0) + } + + @Test("Fit to window is a no-op without a document") + func fitIgnoresMissingDocument() { + let scrollView = NSScrollView(frame: CGRect(x: 0, y: 0, width: 400, height: 300)) + scrollView.allowsMagnification = true + let viewport = DiagramViewportController() + viewport.attach(to: scrollView) + + viewport.fitToWindow() + + #expect(viewport.magnification == 1.0) + } + + @Test("Scrolling by a delta moves the visible rect") + func scrollByMovesViewport() { + let (viewport, scrollView) = makeAttached() + let before = scrollView.contentView.bounds.origin + + viewport.scrollBy(CGSize(width: 60, height: 40)) + + #expect(scrollView.contentView.bounds.origin != before) + } +} diff --git a/TableProTests/Views/ERDiagram/ERDiagramCanvasContainerViewTests.swift b/TableProTests/Views/ERDiagram/ERDiagramCanvasContainerViewTests.swift deleted file mode 100644 index c27ab9896..000000000 --- a/TableProTests/Views/ERDiagram/ERDiagramCanvasContainerViewTests.swift +++ /dev/null @@ -1,81 +0,0 @@ -import AppKit -import SwiftUI -import Testing - -@testable import TablePro - -@Suite("ERDiagramCanvasContainerView scroll routing") -@MainActor -struct ERDiagramCanvasContainerViewTests { - private func makeContainer() -> (ERDiagramCanvasContainerView, ERDiagramViewModel) { - let viewModel = ERDiagramViewModel(connectionId: UUID(), databaseName: "test", schemaKey: "test") - let view = ERDiagramCanvasContainerView(rootView: Color.clear, viewModel: viewModel) - return (view, viewModel) - } - - private func makeScrollEvent( - deltaX: Int32, - deltaY: Int32, - units: CGScrollEventUnit, - flags: CGEventFlags = [] - ) -> NSEvent? { - guard let cgEvent = CGEvent( - scrollWheelEvent2Source: nil, - units: units, - wheelCount: 2, - wheel1: deltaY, - wheel2: deltaX, - wheel3: 0 - ) else { return nil } - cgEvent.flags = flags - return NSEvent(cgEvent: cgEvent) - } - - @Test("trackpad scroll pans the canvas by the event deltas") - func trackpadScrollPans() throws { - let (view, viewModel) = makeContainer() - let event = try #require(makeScrollEvent(deltaX: 12, deltaY: -8, units: .pixel)) - try #require(event.hasPreciseScrollingDeltas) - try #require(event.scrollingDeltaY != 0) - - view.scrollWheel(with: event) - - #expect(viewModel.canvasOffset == CGPoint(x: event.scrollingDeltaX, y: event.scrollingDeltaY)) - } - - @Test("mouse wheel scroll pans with the line multiplier") - func mouseWheelScrollPans() throws { - let (view, viewModel) = makeContainer() - let event = try #require(makeScrollEvent(deltaX: 0, deltaY: 3, units: .line)) - try #require(!event.hasPreciseScrollingDeltas) - try #require(event.scrollingDeltaY != 0) - - view.scrollWheel(with: event) - - #expect(viewModel.canvasOffset.y == event.scrollingDeltaY * 10) - } - - @Test("command scroll zooms through the view model") - func commandScrollZooms() throws { - let (view, viewModel) = makeContainer() - let event = try #require(makeScrollEvent(deltaX: 0, deltaY: 40, units: .pixel, flags: .maskCommand)) - try #require(event.scrollingDeltaY != 0) - - view.scrollWheel(with: event) - - let expected = 1.0 + event.scrollingDeltaY * 0.01 - #expect(abs(viewModel.magnification - expected) < 0.0001) - #expect(viewModel.canvasOffset == .zero) - } - - @Test("command scroll zoom clamps to the maximum magnification") - func commandScrollZoomClamps() throws { - let (view, viewModel) = makeContainer() - let event = try #require(makeScrollEvent(deltaX: 0, deltaY: 400, units: .pixel, flags: .maskCommand)) - try #require(event.scrollingDeltaY >= 200) - - view.scrollWheel(with: event) - - #expect(viewModel.magnification == 3.0) - } -} diff --git a/TableProTests/Views/Main/ClearQueryResultsTests.swift b/TableProTests/Views/Main/ClearQueryResultsTests.swift index 867200f8a..5c3b1c055 100644 --- a/TableProTests/Views/Main/ClearQueryResultsTests.swift +++ b/TableProTests/Views/Main/ClearQueryResultsTests.swift @@ -65,6 +65,57 @@ struct ClearQueryResultsTests { #expect(coordinator.canClearActiveQueryResults == true) } + @Test("Clearing results also drops a query plan") + @MainActor + func clearDropsExplainResult() throws { + let coordinator = Self.makeCoordinator() + defer { coordinator.teardown() } + + coordinator.tabManager.addTab(databaseName: "db") + let index = try #require(coordinator.tabManager.selectedTabIndex) + let plan = ExplainResultSetFactory.make( + rawText: "Seq Scan on orders", plan: nil, sql: "EXPLAIN SELECT 1", executionTime: 0.4 + ) + coordinator.tabManager.mutate(at: index) { tab in + tab.display.resultSets = [plan] + tab.display.activeResultSetId = plan.id + } + + coordinator.clearActiveQueryResults() + + let tab = try #require(coordinator.tabManager.selectedTab) + #expect(tab.display.resultSets.isEmpty) + #expect(tab.display.activeExplainResult == nil) + } + + @Test("Clearing results drops an unpinned plan but keeps a pinned result") + @MainActor + func clearDropsExplainResultWithPinnedResults() throws { + let coordinator = Self.makeCoordinator() + defer { coordinator.teardown() } + + coordinator.tabManager.addTab(databaseName: "db") + let tabId = try #require(coordinator.tabManager.selectedTab?.id) + let index = try #require(coordinator.tabManager.selectedTabIndex) + + let pinned = ResultSet(label: "Result 1") + pinned.isPinned = true + let plan = ExplainResultSetFactory.make( + rawText: "Seq Scan on orders", plan: nil, sql: "EXPLAIN SELECT 1", executionTime: 0.4 + ) + coordinator.tabManager.mutate(at: index) { tab in + tab.display.resultSets = [pinned, plan] + tab.display.activeResultSetId = plan.id + } + + coordinator.clearActiveQueryResults() + + let tab = try #require(coordinator.tabManager.selectedTab) + #expect(tab.display.resultSets.map(\.id) == [pinned.id]) + #expect(tab.display.activeExplainResult == nil) + #expect(tabId == tab.id) + } + @Test("Cannot clear results on a table tab") @MainActor func cannotClearOnTableTab() throws { diff --git a/TableProTests/Views/Main/ResultPinningTests.swift b/TableProTests/Views/Main/ResultPinningTests.swift index bfdd4144c..8254ed2c8 100644 --- a/TableProTests/Views/Main/ResultPinningTests.swift +++ b/TableProTests/Views/Main/ResultPinningTests.swift @@ -226,19 +226,16 @@ struct ResultPinningTests { let result = Self.makeResultSet(label: "Result") for mode in [ResultsViewMode.data, .json, .structure] { - for explainText in [nil, "plan"] as [String?] { - coordinator.tabManager.mutate(at: index) { tab in - tab.display.resultSets = [result] - tab.display.activeResultSetId = result.id - tab.display.resultsViewMode = mode - tab.display.explainText = explainText - } - let tab = try #require(coordinator.tabManager.selectedTab) - #expect( - coordinator.canPinActiveResultSet - == ResultTabBarPolicy.canPin(tabType: tab.tabType, display: tab.display) - ) + coordinator.tabManager.mutate(at: index) { tab in + tab.display.resultSets = [result] + tab.display.activeResultSetId = result.id + tab.display.resultsViewMode = mode } + let tab = try #require(coordinator.tabManager.selectedTab) + #expect( + coordinator.canPinActiveResultSet + == ResultTabBarPolicy.canPin(tabType: tab.tabType, display: tab.display) + ) } } diff --git a/TableProTests/Views/QueryPlan/DiagramZoomTests.swift b/TableProTests/Views/QueryPlan/DiagramZoomTests.swift new file mode 100644 index 000000000..6aeb36fbc --- /dev/null +++ b/TableProTests/Views/QueryPlan/DiagramZoomTests.swift @@ -0,0 +1,46 @@ +// +// DiagramZoomTests.swift +// TableProTests +// +// Tests for the zoom bounds shared by the ER and query plan diagrams. +// + +import CoreGraphics +@testable import TablePro +import Testing + +@Suite("Diagram Zoom") +struct DiagramZoomTests { + @Test("pinch scales from the gesture start") + func scalesFromGestureStart() { + let magnification = DiagramZoom.scaled(from: 1.5, by: 1.2) + #expect(abs(magnification - 1.8) < 0.0001) + } + + @Test("pinch clamps to the supported range") + func clampsPinchRange() { + #expect(DiagramZoom.scaled(from: 2.0, by: 2.0) == 3.0) + #expect(DiagramZoom.scaled(from: 0.5, by: 0.1) == 0.25) + } + + @Test("invalid pinch values preserve the current zoom") + func rejectsInvalidPinchValues() { + #expect(DiagramZoom.scaled(from: 1.5, by: .nan) == 1.5) + #expect(DiagramZoom.scaled(from: 1.5, by: .infinity) == 1.5) + #expect(DiagramZoom.scaled(from: 1.5, by: 0) == 1.5) + #expect(DiagramZoom.scaled(from: 1.5, by: -1) == 1.5) + } + + @Test("button zoom values use the same bounds") + func clampsButtonZoomRange() { + #expect(DiagramZoom.clamped(-10) == DiagramZoom.minimum) + #expect(DiagramZoom.clamped(10) == DiagramZoom.maximum) + #expect(DiagramZoom.clamped(.nan) == 1.0) + } + + @Test("a released pinch resolves to the gesture's final scale") + func resolvesEndedPinch() { + #expect(DiagramZoom.scaled(from: 1.0, by: 2.5) == 2.5) + #expect(DiagramZoom.scaled(from: 2.5, by: 1.0) == 2.5) + } +} diff --git a/TableProTests/Views/QueryPlan/QueryPlanDiagramLayoutTests.swift b/TableProTests/Views/QueryPlan/QueryPlanDiagramLayoutTests.swift new file mode 100644 index 000000000..e6e223638 --- /dev/null +++ b/TableProTests/Views/QueryPlan/QueryPlanDiagramLayoutTests.swift @@ -0,0 +1,127 @@ +// +// QueryPlanDiagramLayoutTests.swift +// TableProTests +// +// Tests that the plan diagram lays out one row per depth without overlapping boxes. +// + +import CoreGraphics +import Foundation +@testable import TablePro +import Testing + +@Suite("Query Plan Diagram Layout") +struct QueryPlanDiagramLayoutTests { + private func node( + _ operation: String, + relation: String? = nil, + cost: Double? = nil, + rows: Int? = nil, + actualTime: Double? = nil, + children: [QueryPlanNode] = [] + ) -> QueryPlanNode { + QueryPlanNode( + operation: operation, + relation: relation, + schema: nil, + alias: nil, + estimatedStartupCost: nil, + estimatedTotalCost: cost, + estimatedRows: rows, + estimatedWidth: nil, + actualStartupTime: nil, + actualTotalTime: actualTime, + actualRows: nil, + actualLoops: nil, + properties: [:], + children: children + ) + } + + /// A bare node is the shortest box, a node with a relation, cost and timing the tallest. + private func makeMixedHeightPlan() -> QueryPlanNode { + node( + "Nested loop inner join", + cost: 12, + rows: 40, + actualTime: 3.5, + children: [ + node("Hash", children: [ + node("Table scan", relation: "t1", cost: 3, rows: 5, actualTime: 0.5), + ]), + node("Table scan", relation: "t2", cost: 4, rows: 9, actualTime: 0.9, children: [ + node("Filter"), + ]), + ] + ) + } + + @Test("Nodes at the same depth share one row") + func alignsSiblingRows() { + let plan = makeMixedHeightPlan() + let layout = QueryPlanDiagramLayout(root: plan) + let depths = depthByNodeId(plan, depth: 0) + + var topsByDepth: [Int: Set] = [:] + for positioned in layout.nodes { + guard let depth = depths[positioned.id] else { continue } + topsByDepth[depth, default: []].insert(positioned.rect.minY) + } + + #expect(topsByDepth.count == 3) + for (_, tops) in topsByDepth { + #expect(tops.count == 1) + } + } + + @Test("A child never overlaps its parent") + func keepsChildrenBelowParents() { + let layout = QueryPlanDiagramLayout(root: makeMixedHeightPlan()) + let byId = Dictionary(uniqueKeysWithValues: layout.nodes.map { ($0.id, $0) }) + + for positioned in layout.nodes { + guard let parentId = positioned.parentId, let parent = byId[parentId] else { continue } + #expect(positioned.rect.minY >= parent.rect.maxY) + } + } + + @Test("Rows are separated by the standard spacing") + func stacksRowsByTallestNode() { + let plan = makeMixedHeightPlan() + let layout = QueryPlanDiagramLayout(root: plan) + let depths = depthByNodeId(plan, depth: 0) + + let firstRowTop = layout.nodes.first { depths[$0.id] == 0 }?.rect.minY + let firstRowBottom = layout.nodes.filter { depths[$0.id] == 0 }.map { $0.rect.maxY }.max() + let secondRowTop = layout.nodes.first { depths[$0.id] == 1 }?.rect.minY + + #expect(firstRowTop == 40) + #expect(secondRowTop == (firstRowBottom ?? 0) + 40) + } + + @Test("Canvas covers every node") + func canvasCoversAllNodes() { + let layout = QueryPlanDiagramLayout(root: makeMixedHeightPlan()) + let maxX = layout.nodes.map { $0.rect.maxX }.max() ?? 0 + let maxY = layout.nodes.map { $0.rect.maxY }.max() ?? 0 + + #expect(layout.canvasSize.width > maxX) + #expect(layout.canvasSize.height > maxY) + } + + @Test("Every node in the tree is positioned once") + func positionsEveryNode() { + let layout = QueryPlanDiagramLayout(root: makeMixedHeightPlan()) + #expect(layout.nodes.count == 5) + #expect(Set(layout.nodes.map(\.id)).count == 5) + #expect(layout.nodes.filter { $0.parentId == nil }.count == 1) + } + + private func depthByNodeId(_ node: QueryPlanNode, depth: Int) -> [UUID: Int] { + var result = [node.id: depth] + for child in node.children { + result.merge(depthByNodeId(child, depth: depth + 1)) { current, _ in current } + } + return result + } +} diff --git a/TableProTests/Views/QueryPlan/QueryPlanOutlineSortTests.swift b/TableProTests/Views/QueryPlan/QueryPlanOutlineSortTests.swift new file mode 100644 index 000000000..3b69af56a --- /dev/null +++ b/TableProTests/Views/QueryPlan/QueryPlanOutlineSortTests.swift @@ -0,0 +1,119 @@ +// +// QueryPlanOutlineSortTests.swift +// TableProTests +// +// Sorting the plan outline reorders siblings only. Flattening a plan would destroy it, since a +// join's inputs are not interchangeable with a subtree at another depth. +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("Query Plan Outline Sort") +@MainActor +struct QueryPlanOutlineSortTests { + private func node( + _ operation: String, + cost: Double? = nil, + rows: Int? = nil, + actualTime: Double? = nil, + children: [QueryPlanNode] = [] + ) -> QueryPlanNode { + QueryPlanNode( + operation: operation, + relation: nil, schema: nil, alias: nil, + estimatedStartupCost: nil, + estimatedTotalCost: cost, + estimatedRows: rows, + estimatedWidth: nil, + actualStartupTime: nil, + actualTotalTime: actualTime, + actualRows: nil, actualLoops: nil, + properties: [:], + children: children + ) + } + + private func makeTree() -> QueryPlanNode { + node("Nested Loop", cost: 100, rows: 500, actualTime: 9, children: [ + node("Seq Scan", cost: 10, rows: 300, actualTime: 5, children: [ + node("Inner B", cost: 2, rows: 20, actualTime: 1), + node("Inner A", cost: 8, rows: 10, actualTime: 4), + ]), + node("Index Scan", cost: 80, rows: 100, actualTime: 2), + ]) + } + + @Test("Sorting by cost reorders siblings without flattening the tree") + func sortsSiblingsByCost() { + let root = QueryPlanOutlineNode(makeTree()) + let sorted = root.sorted(by: QueryPlanOutlineSort.comparator(key: .cost, ascending: false)) + + #expect(sorted.source.operation == "Nested Loop") + #expect(sorted.children.count == 2) + #expect(sorted.children[0].source.operation == "Index Scan") + #expect(sorted.children[1].source.operation == "Seq Scan") + #expect(sorted.children[1].children.count == 2) + #expect(sorted.children[1].children[0].source.operation == "Inner A") + } + + @Test("Ascending and descending are mirror images") + func mirrorsDirection() { + let root = QueryPlanOutlineNode(makeTree()) + let ascending = root.sorted(by: QueryPlanOutlineSort.comparator(key: .cost, ascending: true)) + let descending = root.sorted(by: QueryPlanOutlineSort.comparator(key: .cost, ascending: false)) + + #expect(ascending.children.map { $0.source.operation }.reversed() + == descending.children.map { $0.source.operation }) + } + + @Test("The root is never reordered away") + func keepsRootInPlace() { + let root = QueryPlanOutlineNode(makeTree()) + for column in QueryPlanOutlineColumn.allCases { + let sorted = root.sorted(by: QueryPlanOutlineSort.comparator(key: column, ascending: true)) + #expect(sorted.source.operation == "Nested Loop") + } + } + + @Test("Sorting preserves every node") + func preservesNodeCount() { + func count(_ node: QueryPlanOutlineNode) -> Int { + 1 + node.children.reduce(0) { $0 + count($1) } + } + + let root = QueryPlanOutlineNode(makeTree()) + let sorted = root.sorted(by: QueryPlanOutlineSort.comparator(key: .rows, ascending: true)) + #expect(count(sorted) == count(root)) + } + + @Test("Nodes with no value sort below nodes that have one") + func sinksMissingValues() { + let tree = node("Root", cost: 10, children: [ + node("No Time", cost: 1), + node("Has Time", cost: 1, actualTime: 4), + ]) + let sorted = QueryPlanOutlineNode(tree) + .sorted(by: QueryPlanOutlineSort.comparator(key: .actualTime, ascending: false)) + + #expect(sorted.children[0].source.operation == "Has Time") + } + + @Test("Names sort A to Z by default, numbers worst first") + func picksSensibleDefaultDirection() { + #expect(QueryPlanOutlineSort.defaultAscending(for: .operation)) + #expect(!QueryPlanOutlineSort.defaultAscending(for: .cost)) + #expect(!QueryPlanOutlineSort.defaultAscending(for: .rows)) + #expect(!QueryPlanOutlineSort.defaultAscending(for: .actualTime)) + } + + @Test("Every column has a localized title and a usable width") + func describesColumns() { + for column in QueryPlanOutlineColumn.allCases { + #expect(!column.title.isEmpty) + #expect(column.minimumWidth > 0) + #expect(column.width >= column.minimumWidth) + } + } +} diff --git a/TableProTests/Views/QueryPlan/QueryPlanPresentationTests.swift b/TableProTests/Views/QueryPlan/QueryPlanPresentationTests.swift new file mode 100644 index 000000000..3b727a2db --- /dev/null +++ b/TableProTests/Views/QueryPlan/QueryPlanPresentationTests.swift @@ -0,0 +1,81 @@ +// +// QueryPlanPresentationTests.swift +// TableProTests +// +// What the plan pane shows: a parsed tree, the raw output, or an explicit empty state. A nil +// plan used to fall through to a blank rectangle. +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("Query Plan Presentation") +struct QueryPlanPresentationTests { + private var samplePlan: QueryPlan { + QueryPlan( + rootNode: QueryPlanNode( + operation: "Seq Scan", + relation: nil, schema: nil, alias: nil, + estimatedStartupCost: nil, estimatedTotalCost: 1, + estimatedRows: nil, estimatedWidth: nil, + actualStartupTime: nil, actualTotalTime: nil, + actualRows: nil, actualLoops: nil, + properties: [:], children: [] + ), + planningTime: nil, + executionTime: nil, + rawText: "Seq Scan" + ) + } + + @Test("A parsed plan wins over the raw text") + func prefersParsedPlan() { + let presentation = QueryPlanPresentation.resolve(plan: samplePlan, rawText: "Seq Scan") + #expect(presentation.kind == .parsed) + #expect(presentation.plan != nil) + #expect(presentation.rawText == nil) + } + + @Test("Unparsed output falls back to raw rather than a blank pane") + func fallsBackToRaw() { + let presentation = QueryPlanPresentation.resolve(plan: nil, rawText: "some driver output") + #expect(presentation.kind == .rawOnly) + #expect(presentation.rawText == "some driver output") + #expect(presentation.plan == nil) + } + + @Test("Nothing at all resolves to the empty state") + func resolvesEmpty() { + #expect(QueryPlanPresentation.resolve(plan: nil, rawText: "").kind == .empty) + #expect(QueryPlanPresentation.resolve(plan: nil, rawText: " \n ").kind == .empty) + } + + @Test("Raw output is trimmed before display") + func trimsRawOutput() { + let presentation = QueryPlanPresentation.resolve(plan: nil, rawText: "\n plan text \n") + #expect(presentation.rawText == "plan text") + } + + @Test("Every view mode has a localized title") + func modesAreTitled() { + for mode in QueryPlanViewMode.allCases { + #expect(!mode.title.isEmpty) + } + #expect(QueryPlanViewMode.allCases.count == 3) + } + + @Test("An explain result set is recognised by its raw text, not by a parsed plan") + @MainActor + func recognisesExplainResultSet() { + let unparsed = ExplainResultSetFactory.make( + rawText: "raw", plan: nil, sql: "EXPLAIN SELECT 1", executionTime: 0.1 + ) + #expect(unparsed.isExplainResult) + #expect(unparsed.queryPlan == nil) + #expect(unparsed.baseQuery == "EXPLAIN SELECT 1") + + let ordinary = ResultSet(label: "Result") + #expect(!ordinary.isExplainResult) + } +} diff --git a/TableProUITests/QueryPlanResultUITests.swift b/TableProUITests/QueryPlanResultUITests.swift new file mode 100644 index 000000000..6cabb5ce7 --- /dev/null +++ b/TableProUITests/QueryPlanResultUITests.swift @@ -0,0 +1,121 @@ +// +// QueryPlanResultUITests.swift +// TableProUITests +// +// A query plan is an ordinary result set now, so it appears in the result tab strip and can be +// pinned. The sample SQLite database makes EXPLAIN QUERY PLAN output deterministic, with no +// server to reach. +// + +import XCTest + +final class QueryPlanResultUITests: XCTestCase { + override func setUpWithError() throws { + continueAfterFailure = false + } + + override func tearDownWithError() throws { + XCUIApplication().terminate() + } + + func testExplainProducesAResultTabAlongsideTheData() throws { + let app = launchWithSampleDatabase() + runQuery("EXPLAIN QUERY PLAN SELECT * FROM users;", in: app) + + let resultTab = app.buttons["result-tab"].firstMatch + XCTAssertTrue( + resultTab.waitForExistence(timeout: 20), + "A plan must arrive as a result tab, not as a takeover of the results pane" + ) + + let modePicker = app.radioGroups["query-plan-mode-picker"].firstMatch + XCTAssertTrue( + modePicker.waitForExistence(timeout: 10), + "A parsed plan must offer the Diagram, Tree and Raw modes" + ) + } + + func testTreeModeShowsTheOutlineWithColumns() throws { + let app = launchWithSampleDatabase() + runQuery("EXPLAIN QUERY PLAN SELECT * FROM users;", in: app) + + let modePicker = app.radioGroups["query-plan-mode-picker"].firstMatch + XCTAssertTrue(modePicker.waitForExistence(timeout: 20)) + modePicker.radioButtons["Tree"].click() + + let outline = app.outlines["query-plan-outline"].firstMatch + XCTAssertTrue(outline.waitForExistence(timeout: 10), "Tree mode must show the plan outline") + XCTAssertTrue(outline.outlineRows.count > 0, "The outline must list the plan's steps") + + let detail = app.descendants(matching: .any).matching(identifier: "query-plan-detail-pane").firstMatch + XCTAssertTrue(detail.waitForExistence(timeout: 10), "Selecting a step must fill the detail pane") + } + + func testDiagramModeShowsTheScrollableCanvas() throws { + let app = launchWithSampleDatabase() + runQuery("EXPLAIN QUERY PLAN SELECT * FROM users;", in: app) + + let canvas = app.descendants(matching: .any).matching(identifier: "query-plan-diagram").firstMatch + XCTAssertTrue(canvas.waitForExistence(timeout: 20), "Diagram mode must show the plan canvas") + } + + func testAPlanCanBePinnedLikeAnyResult() throws { + let app = launchWithSampleDatabase() + runQuery("EXPLAIN QUERY PLAN SELECT * FROM users;", in: app) + + let resultTab = app.buttons["result-tab"].firstMatch + XCTAssertTrue(resultTab.waitForExistence(timeout: 20)) + resultTab.rightClick() + + let contextMenu = app.menus.firstMatch + XCTAssertTrue( + contextMenu.menuItems["Pin Result"].waitForExistence(timeout: 5), + "A plan is a result set, so it must offer Pin Result" + ) + contextMenu.menuItems["Pin Result"].click() + + let menuBar = app.menuBars.firstMatch + menuBar.menuBarItems["View"].click() + let unpinItem = menuBar.menuItems["Unpin Result"] + XCTAssertTrue(unpinItem.waitForExistence(timeout: 5), "A pinned plan reads as Unpin Result") + XCTAssertTrue(unpinItem.isEnabled) + app.typeKey(.escape, modifierFlags: []) + } + + // MARK: - Helpers + + private func runQuery(_ sql: String, in app: XCUIApplication) { + let editor = editorTextView(in: app) + XCTAssertTrue(editor.waitForExistence(timeout: 15)) + + app.typeKey("t", modifierFlags: .command) + let queryEditor = editorTextView(in: app) + XCTAssertTrue(queryEditor.waitForExistence(timeout: 10)) + queryEditor.click() + app.typeText(sql) + app.typeKey(.return, modifierFlags: .command) + } + + private func launchWithSampleDatabase() -> XCUIApplication { + let app = XCUIApplication() + app.launchEnvironment["TABLEPRO_UI_TESTING"] = "1" + app.launch() + + let menuBar = app.menuBars.firstMatch + XCTAssertTrue(menuBar.waitForExistence(timeout: 10)) + menuBar.menuBarItems["File"].click() + let openSample = menuBar.menuItems["Open Sample Database"] + XCTAssertTrue(openSample.waitForExistence(timeout: 5)) + openSample.click() + return app + } + + private func editorTextView(in app: XCUIApplication) -> XCUIElement { + let window = app.windows.firstMatch + let identified = window.textViews.matching(identifier: "sql-editor-textview").firstMatch + if identified.exists { + return identified + } + return window.textViews.firstMatch + } +} diff --git a/docs/databases/mysql.mdx b/docs/databases/mysql.mdx index 44b79c2fc..384a8cdfc 100644 --- a/docs/databases/mysql.mdx +++ b/docs/databases/mysql.mdx @@ -58,7 +58,7 @@ Turn on **Cloud SQL Auth Proxy** in the connection form and enter the instance c ## Query Plans -**EXPLAIN** and **EXPLAIN FORMAT=JSON** results render as a visual plan diagram. See [EXPLAIN Visualization](/features/explain-visualization). +**EXPLAIN FORMAT=JSON**, **EXPLAIN FORMAT=TREE**, and **EXPLAIN ANALYZE** results render as a visual plan diagram or tree. Plain multi-column **EXPLAIN** output stays in the results grid. See [EXPLAIN Visualization](/features/explain-visualization). Visual EXPLAIN plan diagram diff --git a/docs/features/explain-visualization.mdx b/docs/features/explain-visualization.mdx index 8a7db3c9b..ec1e19d29 100644 --- a/docs/features/explain-visualization.mdx +++ b/docs/features/explain-visualization.mdx @@ -7,7 +7,22 @@ Click **Explain** in the query editor toolbar, or use **Query > Explain Query** Databases with multiple EXPLAIN variants show a dropdown: PostgreSQL offers **EXPLAIN** (estimated plan) and **EXPLAIN ANALYZE** (runs the query and shows actual timing); MySQL and MariaDB offer **EXPLAIN** and **EXPLAIN (JSON)**. Databases with a single variant, like SQLite or DuckDB, show a plain Explain button. -Typing an `EXPLAIN`, `EXPLAIN ANALYZE`, `EXPLAIN FORMAT=JSON`, or MariaDB's `ANALYZE FORMAT=JSON` statement in the editor and running it opens the same plan viewer, as long as the plan comes back in a single column. Multi-column plans like MySQL's plain `EXPLAIN` table stay in the results grid. +Typing an `EXPLAIN`, `EXPLAIN ANALYZE`, `EXPLAIN FORMAT=JSON`, or MariaDB's `ANALYZE FORMAT=JSON` statement in the editor and running it opens the same plan viewer. MySQL's TREE and ANALYZE text output is parsed into the diagram and tree views. Multi-column plans like MySQL's plain `EXPLAIN` table stay in the results grid, but a multi-column plan the driver itself declares, such as SQLite's `EXPLAIN QUERY PLAN`, still opens the viewer. + +The plan arrives as a result tab next to your query results, so you can switch back to the data without running the query again, and you can pin a plan to keep it while you try another query. + + + MySQL EXPLAIN ANALYZE rendered as a plan diagram + MySQL EXPLAIN ANALYZE rendered as a plan diagram + -**Tree** shows the plan as an expandable outline list. Click a row to see its properties in the detail panel below. Cost and row estimates are shown on the right side of each row. +**Tree** shows the plan as an expandable outline with **Operation**, **Cost**, **Rows** and **Actual Time** columns. Drag a column edge to resize it and TablePro remembers the width. Click a column header to sort, which reorders the steps under each parent without flattening the plan. Arrow keys move between steps, and right-click copies a step. The detail panel below the outline can be resized by dragging the divider. **Raw** shows the original EXPLAIN output as text, with a copy button and a font size stepper in the toolbar. When the plan includes timing, planning and execution times are shown next to the view switcher. @@ -50,14 +67,16 @@ Toggle between three views using the segmented control above the results: | Database | Variants | Output | |----------|----------|--------| | PostgreSQL | EXPLAIN, EXPLAIN ANALYZE | JSON, parsed into diagram and tree | +| PGlite | EXPLAIN, EXPLAIN ANALYZE | PostgreSQL's `EXPLAIN (FORMAT JSON)`, parsed into diagram and tree | | Redshift | EXPLAIN, EXPLAIN ANALYZE | PostgreSQL's `EXPLAIN (FORMAT JSON)`, read by the PostgreSQL plan parser | | CockroachDB | EXPLAIN, EXPLAIN ANALYZE | Text plan, parsed into diagram and tree | -| MySQL / MariaDB | EXPLAIN, EXPLAIN (JSON) | JSON variant parsed into diagram and tree; plain EXPLAIN stays in the results grid | +| MySQL | EXPLAIN, EXPLAIN (JSON); typed EXPLAIN FORMAT=TREE or EXPLAIN ANALYZE | JSON and TREE output parsed into diagram and tree; plain EXPLAIN stays in the results grid | +| MariaDB | EXPLAIN, EXPLAIN (JSON); typed ANALYZE FORMAT=JSON | JSON output parsed into diagram and tree; plain EXPLAIN stays in the results grid | | SQLite | Explain | EXPLAIN QUERY PLAN, parsed into diagram and tree | | ClickHouse | Plan, Pipeline, AST, Syntax, Estimate | Indented text, parsed into diagram and tree | | DuckDB | Explain | Indented text, parsed into diagram and tree | -| Cloudflare D1 | Query Plan | EXPLAIN QUERY PLAN, raw text | -| LibSQL / Turso | Query Plan | EXPLAIN QUERY PLAN, raw text | +| Cloudflare D1 | Query Plan | EXPLAIN QUERY PLAN, parsed into diagram and tree | +| LibSQL / Turso | Query Plan | EXPLAIN QUERY PLAN, parsed into diagram and tree | | Snowflake | Explain (Text) | Raw text | | Trino | Explain (Logical), Explain (Distributed), Explain (IO), Explain (Validate), Explain Analyze | Raw text | | SurrealDB | Explain, Explain Full | Raw text | @@ -77,6 +96,10 @@ Each node in the plan shows: Click a node to see all properties including join type, index name, filter conditions, and sort keys. + +`EXPLAIN ANALYZE` runs the query. When Safe Mode asks for confirmation before running a query, it now asks before an EXPLAIN too, whichever way you started it. Use the Stop button to cancel one that is taking too long. + + EXPLAIN does not execute the query. EXPLAIN ANALYZE executes it and shows actual timing; use it cautiously on production systems. diff --git a/docs/images/explain-diagram-dark.png b/docs/images/explain-diagram-dark.png index 98dab2d71..3152b7671 100644 Binary files a/docs/images/explain-diagram-dark.png and b/docs/images/explain-diagram-dark.png differ diff --git a/docs/images/explain-diagram.png b/docs/images/explain-diagram.png index 234bedc0c..d82f896b9 100644 Binary files a/docs/images/explain-diagram.png and b/docs/images/explain-diagram.png differ diff --git a/docs/images/explain-tree-dark.png b/docs/images/explain-tree-dark.png index f0160629d..1d8783535 100644 Binary files a/docs/images/explain-tree-dark.png and b/docs/images/explain-tree-dark.png differ diff --git a/docs/images/explain-tree.png b/docs/images/explain-tree.png index 097586c12..8bcb8b5bc 100644 Binary files a/docs/images/explain-tree.png and b/docs/images/explain-tree.png differ diff --git a/docs/images/mysql-explain-analyze-dark.png b/docs/images/mysql-explain-analyze-dark.png new file mode 100644 index 000000000..d735923de Binary files /dev/null and b/docs/images/mysql-explain-analyze-dark.png differ diff --git a/docs/images/mysql-explain-analyze.png b/docs/images/mysql-explain-analyze.png new file mode 100644 index 000000000..8d07609a7 Binary files /dev/null and b/docs/images/mysql-explain-analyze.png differ