diff --git a/CHANGELOG.md b/CHANGELOG.md index 933452df5..970954a51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Maintenance tools via table context menu (VACUUM, ANALYZE, OPTIMIZE, REINDEX, CHECK TABLE, etc.) +- EXPLAIN plan visualization with diagram, tree, and raw views (PostgreSQL, MySQL) ### Fixed diff --git a/Plugins/MySQLDriverPlugin/MySQLPlugin.swift b/Plugins/MySQLDriverPlugin/MySQLPlugin.swift index c0cbf9cf3..1ef0b7d8a 100644 --- a/Plugins/MySQLDriverPlugin/MySQLPlugin.swift +++ b/Plugins/MySQLDriverPlugin/MySQLPlugin.swift @@ -28,6 +28,9 @@ final class MySQLPlugin: NSObject, TableProPlugin, DriverPlugin { // MARK: - UI/Capability Metadata static let urlSchemes: [String] = ["mysql"] + static let explainVariants: [ExplainVariant] = [ + ExplainVariant(id: "explain", label: "EXPLAIN", sqlPrefix: "EXPLAIN FORMAT=JSON"), + ] static let brandColorHex = "#FF9500" static let systemDatabaseNames: [String] = ["information_schema", "mysql", "performance_schema", "sys"] static let columnTypesByCategory: [String: [String]] = [ diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPlugin.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPlugin.swift index 24fef7265..23412737e 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPlugin.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPlugin.swift @@ -39,6 +39,10 @@ final class PostgreSQLPlugin: NSObject, TableProPlugin, DriverPlugin { static let brandColorHex = "#336791" static let systemDatabaseNames: [String] = ["postgres", "template0", "template1"] static let supportsSchemaSwitching = true + static let explainVariants: [ExplainVariant] = [ + ExplainVariant(id: "explain", label: "EXPLAIN", sqlPrefix: "EXPLAIN (FORMAT JSON)"), + ExplainVariant(id: "analyze", label: "EXPLAIN ANALYZE", sqlPrefix: "EXPLAIN (ANALYZE, FORMAT JSON)"), + ] static let databaseGroupingStrategy: GroupingStrategy = .bySchema static let columnTypesByCategory: [String: [String]] = [ "Integer": ["SMALLINT", "INTEGER", "BIGINT", "SERIAL", "BIGSERIAL", "SMALLSERIAL"], diff --git a/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift b/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift index d3db2e5e3..34b46c050 100644 --- a/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift +++ b/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift @@ -14,6 +14,10 @@ final class SQLitePlugin: NSObject, TableProPlugin, DriverPlugin { static let pluginDescription = "SQLite file-based database support" static let capabilities: [PluginCapability] = [.databaseDriver] + static let explainVariants: [ExplainVariant] = [ + ExplainVariant(id: "explain", label: "Explain", sqlPrefix: "EXPLAIN QUERY PLAN") + ] + static let databaseTypeId = "SQLite" static let databaseDisplayName = "SQLite" static let iconName = "sqlite-icon" diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift index 91ba91c42..7575c6dc4 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift @@ -766,7 +766,11 @@ extension PluginMetadataRegistry { displayName: "DuckDB", iconName: "duckdb-icon", defaultPort: 0, requiresAuthentication: false, supportsForeignKeys: true, supportsSchemaEditing: true, isDownloadable: true, primaryUrlScheme: "duckdb", parameterStyle: .dollar, - navigationModel: .standard, explainVariants: [], pathFieldRole: .filePath, + navigationModel: .standard, + explainVariants: [ + ExplainVariant(id: "explain", label: "EXPLAIN", sqlPrefix: "EXPLAIN"), + ], + pathFieldRole: .filePath, supportsHealthMonitor: false, urlSchemes: ["duckdb"], postConnectActions: [], brandColorHex: "#FFD900", queryLanguageName: "SQL", editorLanguage: .sql, diff --git a/TablePro/Core/Services/Query/QueryPlanParser.swift b/TablePro/Core/Services/Query/QueryPlanParser.swift new file mode 100644 index 000000000..ddf5b3b1c --- /dev/null +++ b/TablePro/Core/Services/Query/QueryPlanParser.swift @@ -0,0 +1,366 @@ +// +// QueryPlanParser.swift +// TablePro +// +// Parses EXPLAIN output into QueryPlan tree for visualization. +// + +import Foundation +import os + +private let logger = Logger(subsystem: "com.TablePro", category: "QueryPlanParser") + +// MARK: - Parser Protocol + +protocol QueryPlanParser { + func parse(rawText: String) -> QueryPlan? +} + +// MARK: - PostgreSQL JSON Parser + +/// Parses PostgreSQL `EXPLAIN (FORMAT JSON)` and `EXPLAIN (ANALYZE, FORMAT JSON)` output. +struct PostgreSQLPlanParser: QueryPlanParser { + func parse(rawText: String) -> QueryPlan? { + guard let data = rawText.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]], + let planDict = json.first, + let plan = planDict["Plan"] as? [String: Any] + else { + logger.debug("Failed to parse PostgreSQL EXPLAIN JSON") + return nil + } + + let planningTime = planDict["Planning Time"] as? Double + let executionTime = planDict["Execution Time"] as? Double + let rootNode = parseNode(plan) + + var queryPlan = QueryPlan( + rootNode: rootNode, + planningTime: planningTime, + executionTime: executionTime, + rawText: rawText + ) + queryPlan.computeCostFractions() + return queryPlan + } + + private func parseNode(_ dict: [String: Any]) -> QueryPlanNode { + let children: [QueryPlanNode] + if let plans = dict["Plans"] as? [[String: Any]] { + children = plans.map { parseNode($0) } + } else { + children = [] + } + + // Collect all properties except the ones we extract explicitly + let knownKeys: Set = [ + "Node Type", "Relation Name", "Schema", "Alias", + "Startup Cost", "Total Cost", "Plan Rows", "Plan Width", + "Actual Startup Time", "Actual Total Time", "Actual Rows", "Actual Loops", + "Plans", + ] + var properties: [String: String] = [:] + for (key, value) in dict where !knownKeys.contains(key) { + properties[key] = "\(value)" + } + + return QueryPlanNode( + operation: dict["Node Type"] as? String ?? "Unknown", + relation: dict["Relation Name"] as? String, + schema: dict["Schema"] as? String, + alias: dict["Alias"] as? String, + estimatedStartupCost: dict["Startup Cost"] as? Double, + estimatedTotalCost: dict["Total Cost"] as? Double, + estimatedRows: dict["Plan Rows"] as? Int, + estimatedWidth: dict["Plan Width"] as? Int, + actualStartupTime: dict["Actual Startup Time"] as? Double, + actualTotalTime: dict["Actual Total Time"] as? Double, + actualRows: dict["Actual Rows"] as? Int, + actualLoops: dict["Actual Loops"] as? Int, + properties: properties, + children: children + ) + } +} + +// MARK: - MySQL JSON Parser + +/// 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 { + func parse(rawText: String) -> QueryPlan? { + guard let data = rawText.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let queryBlock = json["query_block"] as? [String: Any] + else { + logger.debug("Failed to parse MySQL EXPLAIN JSON") + return nil + } + + let cost = queryBlock["cost"] as? Double + ?? (queryBlock["cost_info"] as? [String: Any])?["query_cost"].flatMap { Double("\($0)") } + let rootNode = parseBlock(queryBlock, operation: "Query Block", cost: cost) + + var queryPlan = QueryPlan( + rootNode: rootNode, + planningTime: nil, + executionTime: nil, + rawText: rawText + ) + queryPlan.computeCostFractions() + return queryPlan + } + + /// Recursively parse a JSON dict, looking for known operation keys. + private func parseBlock(_ dict: [String: Any], operation: String, cost: Double?) -> QueryPlanNode { + var children: [QueryPlanNode] = [] + + // Direct table access + if let table = dict["table"] as? [String: Any] { + children.append(parseTable(table)) + } + + // Nested loop (array of table entries) + if let nestedLoop = dict["nested_loop"] as? [[String: Any]] { + for item in nestedLoop { + if let table = item["table"] as? [String: Any] { + children.append(parseTable(table)) + } + } + } + + // MariaDB: filesort wraps the inner plan + if let filesort = dict["filesort"] as? [String: Any] { + let sortKey = filesort["sort_key"] as? String + let sortOp = sortKey.map { "Sort (\($0))" } ?? "Sort" + let inner = parseBlock(filesort, operation: sortOp, cost: nil) + if !inner.children.isEmpty { + children = [inner] + } + } + + // MariaDB: temporary_table wraps nested_loop + if let tempTable = dict["temporary_table"] as? [String: Any] { + let inner = parseBlock(tempTable, operation: "Temporary Table", cost: nil) + if !inner.children.isEmpty { + children = inner.children + } + } + + // MySQL: ordering_operation + if let orderingOp = dict["ordering_operation"] as? [String: Any] { + let inner = parseBlock(orderingOp, operation: "Sort", cost: nil) + children.append(inner) + } + + // MySQL: grouping_operation + if let groupingOp = dict["grouping_operation"] as? [String: Any] { + let inner = parseBlock(groupingOp, operation: "Group", cost: nil) + children.append(inner) + } + + return QueryPlanNode( + operation: operation, + relation: nil, schema: nil, alias: nil, + estimatedStartupCost: nil, + estimatedTotalCost: cost, + estimatedRows: nil, estimatedWidth: nil, + actualStartupTime: nil, actualTotalTime: nil, + actualRows: nil, actualLoops: nil, + properties: [:], + children: children + ) + } + + private func parseTable(_ table: [String: Any]) -> QueryPlanNode { + // MariaDB uses "cost" directly, MySQL uses "cost_info.read_cost" + let cost = table["cost"] as? Double + ?? (table["cost_info"] as? [String: Any])?["read_cost"].flatMap { Double("\($0)") } + let rows = table["rows"] as? Int + ?? table["rows_examined_per_scan"] as? Int + ?? table["rows_produced_per_join"] as? Int + + var properties: [String: String] = [:] + if let key = table["key"] as? String { properties["Key"] = key } + if let ref = table["ref"] as? [String] { properties["Ref"] = ref.joined(separator: ", ") } + if let cond = table["attached_condition"] as? String { properties["Filter"] = cond } + if let filtered = table["filtered"] as? Double, filtered < 100 { + properties["Filtered"] = String(format: "%.0f%%", filtered) + } + + return QueryPlanNode( + operation: table["access_type"] as? String ?? "ALL", + relation: table["table_name"] as? String, + schema: nil, alias: nil, + estimatedStartupCost: nil, + estimatedTotalCost: cost, + estimatedRows: rows, + estimatedWidth: nil, + actualStartupTime: nil, actualTotalTime: nil, + actualRows: nil, actualLoops: nil, + properties: properties, + children: [] + ) + } +} + +// MARK: - SQLite Parser + +/// Parses SQLite `EXPLAIN QUERY PLAN` output (id/parent/notused/detail columns). +struct SQLitePlanParser: QueryPlanParser { + func parse(rawText: String) -> QueryPlan? { + let lines = rawText.components(separatedBy: "\n").filter { !$0.isEmpty } + guard !lines.isEmpty else { return nil } + + // SQLite EXPLAIN QUERY PLAN returns: id | parent | notused | detail + // Parse tab-separated or pipe-separated rows + var nodes: [(id: Int, parent: Int, detail: String)] = [] + for line in lines { + let parts = line.components(separatedBy: "\t") + if parts.count >= 4, + let id = Int(parts[0].trimmingCharacters(in: .whitespaces)), + let parent = Int(parts[1].trimmingCharacters(in: .whitespaces)) { + nodes.append((id: id, parent: parent, detail: parts[3].trimmingCharacters(in: .whitespaces))) + } else { + // Fallback: treat entire line as a detail node + nodes.append((id: nodes.count, parent: nodes.isEmpty ? -1 : 0, detail: line)) + } + } + + guard !nodes.isEmpty else { return nil } + + func buildChildren(parentId: Int) -> [QueryPlanNode] { + nodes.filter { $0.parent == parentId }.map { node in + QueryPlanNode( + operation: node.detail, + relation: nil, schema: nil, alias: nil, + estimatedStartupCost: nil, estimatedTotalCost: nil, + estimatedRows: nil, estimatedWidth: nil, + actualStartupTime: nil, actualTotalTime: nil, + actualRows: nil, actualLoops: nil, + properties: [:], + children: buildChildren(parentId: node.id) + ) + } + } + + // 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 + ) + } + + return QueryPlan(rootNode: rootNode, planningTime: nil, executionTime: nil, rawText: rawText) + } +} + +// MARK: - Indented Text Parser (ClickHouse, DuckDB) + +/// Parses indented text EXPLAIN output into a tree based on leading whitespace depth. +/// Works for ClickHouse EXPLAIN, DuckDB EXPLAIN, and any text plan with indentation hierarchy. +struct IndentedTextPlanParser: QueryPlanParser { + func parse(rawText: String) -> QueryPlan? { + let lines = rawText.components(separatedBy: "\n").filter { !$0.isEmpty } + guard !lines.isEmpty else { return nil } + + // Parse each line's indent level and content + struct ParsedLine { + let indent: Int + let text: String + } + let parsed: [ParsedLine] = lines.map { line in + let trimmed = line.drop(while: { $0 == " " || $0 == "\t" }) + let indent = (line as NSString).length - (String(trimmed) as NSString).length + return ParsedLine(indent: indent, text: String(trimmed)) + } + + // Build tree from indentation + func buildNodes(from startIndex: Int, parentIndent: Int) -> (nodes: [QueryPlanNode], nextIndex: Int) { + var nodes: [QueryPlanNode] = [] + var i = startIndex + + while i < parsed.count { + let line = parsed[i] + if line.indent <= parentIndent && i > startIndex { + break + } + + let children: [QueryPlanNode] + let nextI: Int + if i + 1 < parsed.count && parsed[i + 1].indent > line.indent { + let result = buildNodes(from: i + 1, parentIndent: line.indent) + children = result.nodes + nextI = result.nextIndex + } else { + children = [] + nextI = i + 1 + } + + nodes.append(QueryPlanNode( + operation: line.text, + relation: nil, schema: nil, alias: nil, + estimatedStartupCost: nil, estimatedTotalCost: nil, + estimatedRows: nil, estimatedWidth: nil, + actualStartupTime: nil, actualTotalTime: nil, + actualRows: nil, actualLoops: nil, + properties: [:], + children: children + )) + i = nextI + } + return (nodes, i) + } + + 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 + ) + } + + return QueryPlan(rootNode: rootNode, planningTime: nil, executionTime: nil, rawText: rawText) + } +} + +// MARK: - Factory + +enum QueryPlanParserFactory { + static func parser(for databaseType: DatabaseType) -> QueryPlanParser? { + switch databaseType { + case .postgresql, .redshift: + return PostgreSQLPlanParser() + case .mysql, .mariadb: + return MySQLPlanParser() + case .sqlite: + return SQLitePlanParser() + case .clickhouse, .duckdb: + return IndentedTextPlanParser() + default: + return nil + } + } +} diff --git a/TablePro/Models/Query/QueryPlan.swift b/TablePro/Models/Query/QueryPlan.swift new file mode 100644 index 000000000..250b44b47 --- /dev/null +++ b/TablePro/Models/Query/QueryPlan.swift @@ -0,0 +1,58 @@ +// +// QueryPlan.swift +// TablePro +// +// Data model for parsed EXPLAIN query plans. +// + +import Foundation + +/// A single node in an EXPLAIN query plan tree. +struct QueryPlanNode: Identifiable { + let id = UUID() + let operation: String + let relation: String? + let schema: String? + let alias: String? + let estimatedStartupCost: Double? + let estimatedTotalCost: Double? + let estimatedRows: Int? + let estimatedWidth: Int? + let actualStartupTime: Double? + let actualTotalTime: Double? + let actualRows: Int? + let actualLoops: Int? + let properties: [String: String] + var children: [QueryPlanNode] + + /// Fraction of total plan cost (0.0-1.0), set after tree is built. + var costFraction: Double = 0 + + /// Exclusive cost (this node only, excluding children). + var exclusiveCost: Double { + let childCost = children.reduce(0.0) { $0 + ($1.estimatedTotalCost ?? 0) } + return max(0, (estimatedTotalCost ?? 0) - childCost) + } +} + +/// A parsed EXPLAIN query plan. +struct QueryPlan { + var rootNode: QueryPlanNode + let planningTime: Double? + let executionTime: Double? + let rawText: String + + /// Compute cost fractions relative to root total cost. + mutating func computeCostFractions() { + let totalCost = rootNode.estimatedTotalCost ?? 1 + guard totalCost > 0 else { return } + assignFractions(node: &rootNode, totalCost: totalCost) + } + + private func assignFractions(node: inout QueryPlanNode, totalCost: Double) { + node.costFraction = node.exclusiveCost / totalCost + for i in node.children.indices { + assignFractions(node: &node.children[i], totalCost: totalCost) + } + } +} diff --git a/TablePro/Models/Query/QueryTab.swift b/TablePro/Models/Query/QueryTab.swift index 525d034d4..af0c7af10 100644 --- a/TablePro/Models/Query/QueryTab.swift +++ b/TablePro/Models/Query/QueryTab.swift @@ -74,6 +74,7 @@ struct QueryTab: Identifiable, Equatable { var showStructure: Bool // Toggle to show structure view instead of data var explainText: String? var explainExecutionTime: TimeInterval? + var explainPlan: QueryPlan? // Per-tab change tracking (preserves changes when switching tabs) var pendingChanges: TabPendingChanges diff --git a/TablePro/Views/Editor/ExplainResultView.swift b/TablePro/Views/Editor/ExplainResultView.swift index 36350030c..1586b2a2e 100644 --- a/TablePro/Views/Editor/ExplainResultView.swift +++ b/TablePro/Views/Editor/ExplainResultView.swift @@ -2,48 +2,96 @@ // ExplainResultView.swift // TablePro // -// Displays EXPLAIN query results in a monospace text view. +// 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: CGFloat = 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() - DDLTextView(ddl: text, fontSize: $fontSize) + 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) { - HStack(spacing: 4) { - Button(action: { fontSize = max(10, fontSize - 1) }) { - Image(systemName: "textformat.size.smaller") - .frame(width: 24, height: 24) + 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) } - .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) + .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")) } - .accessibilityLabel(String(localized: "Increase font size")) + .buttonStyle(.borderless) } - .buttonStyle(.borderless) - if let time = executionTime { + 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) @@ -55,17 +103,20 @@ struct ExplainResultView: View { HStack { Image(systemName: "checkmark.circle.fill") .foregroundStyle(Color(nsColor: .systemGreen)) - Text("Copied!") + Text(String(localized: "Copied!")) } .transition(.opacity) } Button(action: copyText) { - Label("Copy", systemImage: "doc.on.doc") + Label(String(localized: "Copy"), systemImage: "doc.on.doc") } .buttonStyle(.bordered) + .controlSize(.small) + .help(String(localized: "Copy EXPLAIN output to clipboard")) } - .padding() + .padding(.horizontal, 12) + .padding(.vertical, 8) .background(Color(nsColor: .controlBackgroundColor)) } diff --git a/TablePro/Views/Editor/QueryEditorView.swift b/TablePro/Views/Editor/QueryEditorView.swift index b367f290a..f03636568 100644 --- a/TablePro/Views/Editor/QueryEditorView.swift +++ b/TablePro/Views/Editor/QueryEditorView.swift @@ -124,9 +124,17 @@ struct QueryEditorView: View { PluginMetadataRegistry.shared.snapshot(forTypeId: $0.pluginTypeId)?.explainVariants } ?? [] - if variants.isEmpty { + if variants.count <= 1 { Button { - onExplain?(nil) + if let variant = variants.first { + if let handler = onExplainVariant { + handler(variant) + } else { + onExplain?(nil) + } + } else { + onExplain?(nil) + } } label: { HStack(spacing: 4) { Image(systemName: "chart.bar.doc.horizontal") diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index 8656f68cc..0a726ab14 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -212,6 +212,9 @@ struct MainEditorContentView: View { coordinator.runExplainQuery() } }, + onExplainVariant: { variant in + coordinator.runVariantExplain(variant) + }, onAIExplain: { text in coordinator.showAIChatPanel() coordinator.aiViewModel?.handleExplainSelection(text) @@ -299,7 +302,8 @@ struct MainEditorContentView: View { .id(tableName) .frame(maxHeight: .infinity) } else if let explainText = tab.explainText { - ExplainResultView(text: explainText, executionTime: tab.explainExecutionTime) + ExplainResultView(text: explainText, executionTime: tab.explainExecutionTime, plan: tab.explainPlan) + .frame(maxWidth: .infinity, maxHeight: .infinity) } else { // Result tab bar (when multiple result sets) if tab.resultSets.count > 1 { @@ -363,7 +367,9 @@ struct MainEditorContentView: View { } } - statusBar(tab: tab) + if tab.explainText == nil { + statusBar(tab: tab) + } } .frame(maxWidth: .infinity, maxHeight: .infinity) } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+ClickHouse.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+ClickHouse.swift index 862c715c7..f8c7a6772 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+ClickHouse.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+ClickHouse.swift @@ -56,13 +56,14 @@ extension MainContentCoordinator { guard let stmt = statements.first else { return } let explainSQL = "\(variant.sqlPrefix) \(stmt)" + let tabId = tabManager.tabs[index].id Task { @MainActor in guard let driver = DatabaseManager.shared.driver(for: connectionId) else { return } - tabManager.tabs[index].isExecuting = true - tabManager.tabs[index].explainText = nil - tabManager.tabs[index].explainExecutionTime = nil + if let idx = tabManager.tabs.firstIndex(where: { $0.id == tabId }) { + tabManager.tabs[idx].isExecuting = true + } toolbarState.setExecuting(true) do { @@ -74,13 +75,25 @@ extension MainContentCoordinator { row.compactMap { $0 }.joined(separator: "\t") }.joined(separator: "\n") - tabManager.tabs[index].explainText = text - tabManager.tabs[index].explainExecutionTime = duration + if let idx = tabManager.tabs.firstIndex(where: { $0.id == tabId }) { + tabManager.tabs[idx].explainText = text + tabManager.tabs[idx].explainExecutionTime = duration + + if let parser = QueryPlanParserFactory.parser(for: connection.type) { + tabManager.tabs[idx].explainPlan = parser.parse(rawText: text) + } else { + tabManager.tabs[idx].explainPlan = nil + } + tabManager.tabs[idx].isExecuting = false + } } catch { - tabManager.tabs[index].explainText = "Error: \(error.localizedDescription)" + if let idx = tabManager.tabs.firstIndex(where: { $0.id == tabId }) { + tabManager.tabs[idx].explainText = "Error: \(error.localizedDescription)" + tabManager.tabs[idx].explainPlan = nil + tabManager.tabs[idx].isExecuting = false + } } - tabManager.tabs[index].isExecuting = false toolbarState.setExecuting(false) } } diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 69128b88e..64fd4ae1a 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -834,6 +834,8 @@ final class MainContentCoordinator { tab.isExecuting = true tab.executionTime = nil tab.errorMessage = nil + tab.explainText = nil + tab.explainPlan = nil tabManager.tabs[index] = tab toolbarState.setExecuting(true) diff --git a/TablePro/Views/QueryPlan/QueryPlanDiagramView.swift b/TablePro/Views/QueryPlan/QueryPlanDiagramView.swift new file mode 100644 index 000000000..ecd0a36ee --- /dev/null +++ b/TablePro/Views/QueryPlan/QueryPlanDiagramView.swift @@ -0,0 +1,346 @@ +// +// QueryPlanDiagramView.swift +// TablePro +// +// Canvas-based EXPLAIN plan diagram with boxes and arrows. +// + +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? +} + +// MARK: - Diagram View + +struct QueryPlanDiagramView: View { + let plan: QueryPlan + + @State private var magnification: CGFloat = 1.0 + @State private var selectedNode: SelectedNodeID? + + var body: some View { + let positioned = layoutNodes(plan.rootNode, depth: 0, xOffset: 0, parentId: nil) + let canvasSize = computeCanvasSize(positioned) + + ZStack(alignment: .bottomTrailing) { + ScrollView([.horizontal, .vertical]) { + ZStack(alignment: .topLeading) { + Canvas { context, _ in + drawArrows(context: context, nodes: positioned) + } + .frame(width: canvasSize.width, height: canvasSize.height) + + 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) + } + } + .frame(width: canvasSize.width, height: canvasSize.height) + .scaleEffect(magnification) + .frame( + width: canvasSize.width * magnification, + height: canvasSize.height * magnification, + alignment: .topLeading + ) + } + + zoomControls + .padding(12) + } + } + + // MARK: - Node + + private func diagramNode(_ pos: PositionedNode) -> some View { + let node = pos.node + let isSelected = selectedNode?.id == pos.id + + 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) + } + + 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) + } + } + + if let time = node.actualTotalTime { + Text(String(format: "%.3fms", time)) + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.quaternary) + } + } + .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)" } ?? "")") + } + + // MARK: - Zoom + + 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) + } + .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) + } + + // MARK: - Color + + private func nodeColor(fraction: Double) -> Color { + if fraction > 0.5 { return Color(nsColor: .systemRed) } + if fraction > 0.2 { return Color(nsColor: .systemOrange) } + if fraction > 0.05 { return Color(nsColor: .systemYellow) } + return Color(nsColor: .systemGreen) + } + + // 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.filter { $0.parentId == node.id }.first?.rect.midX ?? xOffset + let lastChildX = childPositions.filter { $0.parentId == node.id }.last?.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) + } + + 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 computeCanvasSize(_ 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 + ) + } + + // MARK: - Arrows + + private func drawArrows(context: GraphicsContext, nodes: [PositionedNode]) { + let nodeMap = Dictionary(uniqueKeysWithValues: nodes.map { ($0.id, $0) }) + + for node in nodes { + guard let parentId = node.parentId, let parent = nodeMap[parentId] else { continue } + + let start = CGPoint(x: parent.rect.midX, y: parent.rect.maxY) + let end = CGPoint(x: node.rect.midX, y: node.rect.minY) + let midY = (start.y + end.y) / 2 + + var path = Path() + path.move(to: start) + path.addCurve(to: end, control1: CGPoint(x: start.x, y: midY), control2: CGPoint(x: end.x, y: midY)) + context.stroke(path, with: .color(.secondary.opacity(0.4)), lineWidth: 1) + + // Arrowhead + var arrow = Path() + let s = PlanLayout.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.closeSubpath() + context.fill(arrow, with: .color(.secondary.opacity(0.4))) + } + } + + // MARK: - Popover + + private static let hiddenKeys: Set = [ + "Parallel Aware", "Async Capable", "Disabled", "Inner Unique", + ] + + 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 } + + return VStack(alignment: .leading, spacing: 6) { + Text(node.operation) + .font(.headline) + + 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)") } + + 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 !filtered.isEmpty { + Divider() + ForEach(filtered, id: \.key) { key, value in + detailRow(key, value) + } + } + } + .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) + } + } + + // MARK: - Popover Binding + + private func popoverBinding(for nodeId: UUID) -> Binding { + Binding( + get: { selectedNode?.id == nodeId }, + set: { if !$0 { selectedNode = nil } } + ) + } + + // 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 + } +} + +// MARK: - Identifiable Wrapper + +private struct SelectedNodeID: Identifiable { + let id: UUID +} diff --git a/TablePro/Views/QueryPlan/QueryPlanTreeView.swift b/TablePro/Views/QueryPlan/QueryPlanTreeView.swift new file mode 100644 index 000000000..d6b719976 --- /dev/null +++ b/TablePro/Views/QueryPlan/QueryPlanTreeView.swift @@ -0,0 +1,216 @@ +// +// QueryPlanTreeView.swift +// TablePro +// +// Native SwiftUI tree view for EXPLAIN query plan visualization. +// Uses OutlineGroup for hierarchical display following macOS HIG. +// + +import SwiftUI + +struct QueryPlanTreeView: View { + let plan: QueryPlan + + @State private var selection: UUID? + + var body: some View { + VStack(spacing: 0) { + // Tree list + List(selection: $selection) { + OutlineGroup( + [plan.rootNode], + id: \.id, + children: \.childrenOrNil + ) { node in + QueryPlanRowView(node: node) + } + } + .listStyle(.inset(alternatesRowBackgrounds: true)) + + // Detail panel for selected node + if let selectedNode = findNode(selection, in: plan.rootNode) { + Divider() + QueryPlanDetailView(node: selectedNode) + .frame(height: 180) + } + } + } + + // 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 + } +} + +// MARK: - Row View + +private struct QueryPlanRowView: View { + static let rowFormatter: NumberFormatter = { + let f = NumberFormatter() + f.numberStyle = .decimal + return f + }() + + let node: QueryPlanNode + + var body: some View { + HStack(spacing: 8) { + // Cost indicator + Circle() + .fill(costColor) + .frame(width: 8, height: 8) + .accessibilityHidden(true) + + // Operation + table + 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) + + // Cost + 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) + } + + // Rows + if let rows = node.estimatedRows { + Text(Self.rowFormatter.string(from: NSNumber(value: rows)).map { "\($0) rows" } ?? "\(rows) 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 Color(nsColor: .systemRed) } + if node.costFraction > 0.2 { return Color(nsColor: .systemOrange) } + if node.costFraction > 0.05 { return Color(nsColor: .systemYellow) } + return Color(nsColor: .systemGreen) + } +} + +// 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) { + // Estimates + 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)") } + } + } + + // Extra properties + 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/docs/docs.json b/docs/docs.json index 19aa0c435..0a995b821 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -51,6 +51,7 @@ "group": "Features", "pages": [ "features/sql-editor", + "features/explain-visualization", "features/data-grid", "features/autocomplete", "features/table-structure", diff --git a/docs/features/explain-visualization.mdx b/docs/features/explain-visualization.mdx new file mode 100644 index 000000000..3624a49d0 --- /dev/null +++ b/docs/features/explain-visualization.mdx @@ -0,0 +1,74 @@ +--- +title: EXPLAIN Visualization +description: View query execution plans as diagrams, trees, or raw output +--- + +# EXPLAIN Visualization + +See how your database executes a query. Click **Explain** in the query editor toolbar to get the execution plan. + +PostgreSQL shows a dropdown with **EXPLAIN** (estimated plan) and **EXPLAIN ANALYZE** (runs the query and shows actual timing). MySQL, MariaDB, SQLite, and other databases show a single Explain button. + + + EXPLAIN diagram view + EXPLAIN diagram view + + +## View Modes + +Toggle between three views using the segmented control above the results: + +**Diagram** shows the plan as boxes connected by arrows, top to bottom. Nodes are color-coded by cost: green (cheap) to red (expensive). Click a node to see full details in a popover. Zoom controls are in the bottom-right corner. + + + EXPLAIN tree view + EXPLAIN tree view + + +**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. + +**Raw** shows the original EXPLAIN output as text (JSON for PostgreSQL and MySQL, plain text for others). + +## Database Support + +| Database | Format | Variants | +|----------|--------|----------| +| PostgreSQL | JSON (parsed into diagram/tree) | EXPLAIN, EXPLAIN ANALYZE | +| MySQL/MariaDB | JSON (parsed into diagram/tree) | EXPLAIN | +| SQLite | EXPLAIN QUERY PLAN (parsed into tree) | Explain | +| ClickHouse | Indented text | Plan, Pipeline, AST, Syntax, Estimate | +| DuckDB | Indented text | Explain | +| Cloudflare D1 | EXPLAIN QUERY PLAN | Query Plan | +| BigQuery | Dry run cost estimate | Dry Run | + +## Plan Details + +Each node in the plan shows: + +- **Operation**: Seq Scan, Index Scan, Hash Join, Nested Loop, Sort, etc. +- **Table**: which table the operation accesses +- **Cost**: startup and total cost estimates (PostgreSQL format: 0.00..45.18) +- **Rows**: estimated number of rows +- **Actual time**: real execution time per node (EXPLAIN ANALYZE only) + +Click a node to see all properties including join type, index name, filter conditions, and sort keys. + + +EXPLAIN does not execute the query. EXPLAIN ANALYZE does execute it, so avoid using ANALYZE on slow queries in production. + diff --git a/docs/images/explain-diagram-dark.png b/docs/images/explain-diagram-dark.png new file mode 100644 index 000000000..98dab2d71 Binary files /dev/null and b/docs/images/explain-diagram-dark.png differ diff --git a/docs/images/explain-diagram.png b/docs/images/explain-diagram.png new file mode 100644 index 000000000..234bedc0c Binary files /dev/null and b/docs/images/explain-diagram.png differ diff --git a/docs/images/explain-tree-dark.png b/docs/images/explain-tree-dark.png new file mode 100644 index 000000000..f0160629d Binary files /dev/null and b/docs/images/explain-tree-dark.png differ diff --git a/docs/images/explain-tree.png b/docs/images/explain-tree.png new file mode 100644 index 000000000..097586c12 Binary files /dev/null and b/docs/images/explain-tree.png differ