From 85fe1dc6a1d1484120d0034f39576f9276efb463 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 29 May 2026 22:58:39 +0700 Subject: [PATCH 1/2] fix(datagrid): show typed EXPLAIN results in the plan viewer (#1480) --- CHANGELOG.md | 1 + .../QueryExecutionCoordinator+Helpers.swift | 48 +++++++++++++++++++ .../Core/Utilities/SQL/QueryClassifier.swift | 8 ++++ .../Views/Main/MainContentCoordinator.swift | 3 ++ .../Utilities/SQL/QueryClassifierTests.swift | 39 +++++++++++++++ docs/features/explain-visualization.mdx | 2 + 6 files changed, 101 insertions(+) create mode 100644 TableProTests/Core/Utilities/SQL/QueryClassifierTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 33ead989d..b3afcf429 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Running `EXPLAIN` or `EXPLAIN ANALYZE` typed in the editor now opens the plan viewer instead of squashing the plan into one truncated grid cell. (#1480) - Moving a connection into or out of a group now syncs across devices, instead of leaving it ungrouped on your other Macs. - Opening a table on a connection with many tables no longer stalls for several seconds while autocomplete and table metadata load. Background schema introspection now runs on separate connections instead of waiting behind, or blocking, the query that fills the grid. (#1483) diff --git a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift index e84d46022..d741e78e0 100644 --- a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift +++ b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift @@ -72,6 +72,18 @@ extension QueryExecutionCoordinator { ) { guard let idx = parent.tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { return } + if QueryClassifier.isExplainStatement(sql), columns.count == 1 { + applyExplainResult( + tabId: tabId, + rows: rows, + executionTime: executionTime, + sql: sql, + connection: conn, + queryParameterValues: queryParameterValues + ) + return + } + let existingTabId = parent.tabManager.tabs[idx].id var columnEnumValues: [String: [String]] = [:] var columnDefaults: [String: String?] = [:] @@ -203,6 +215,42 @@ extension QueryExecutionCoordinator { } } + private func applyExplainResult( + tabId: UUID, + rows: [[PluginCellValue]], + executionTime: TimeInterval, + sql: String, + connection conn: DatabaseConnection, + queryParameterValues: [QueryParameter]? + ) { + let planText = rows.map { $0.first?.asText ?? "" }.joined(separator: "\n") + let plan = QueryPlanParserFactory.parser(for: conn.type)?.parse(rawText: planText) + + parent.tabManager.mutate(tabId: tabId) { tab in + tab.execution.executionTime = executionTime + tab.execution.isExecuting = false + tab.execution.lastExecutedAt = Date() + tab.display.explainText = planText + tab.display.explainPlan = plan + tab.display.explainExecutionTime = executionTime + if tab.display.isResultsCollapsed { + tab.display.isResultsCollapsed = false + } + } + parent.toolbarState.isResultsCollapsed = false + + QueryHistoryManager.shared.recordQuery( + query: sql, + connectionId: conn.id, + databaseName: parent.activeDatabaseName, + executionTime: executionTime, + rowCount: rows.count, + wasSuccessful: true, + errorMessage: nil, + parameterValues: queryParameterValues + ) + } + private func applyDefaultSortIfPending( tabId: UUID, tabIndex: Int, diff --git a/TablePro/Core/Utilities/SQL/QueryClassifier.swift b/TablePro/Core/Utilities/SQL/QueryClassifier.swift index 78a4f1425..6d3c6972b 100644 --- a/TablePro/Core/Utilities/SQL/QueryClassifier.swift +++ b/TablePro/Core/Utilities/SQL/QueryClassifier.swift @@ -130,4 +130,12 @@ enum QueryClassifier { static func isMultiStatement(_ sql: String) -> Bool { SQLStatementScanner.allStatements(in: sql).count > 1 } + + static func isExplainStatement(_ sql: String) -> Bool { + let upper = sql.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() + guard upper.hasPrefix("EXPLAIN"), let boundary = upper.dropFirst("EXPLAIN".count).first else { + return false + } + return boundary == "(" || boundary.isWhitespace + } } diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index cbb27a833..db917f508 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -1144,6 +1144,9 @@ final class MainContentCoordinator { } internal func resolveTableEditability(tab: QueryTab, sql: String) -> (tableName: String?, isEditable: Bool) { + if tab.tabType != .table, QueryClassifier.isExplainStatement(sql) { + return (nil, false) + } let usesNoSQLBrowsing = services.pluginManager.editorLanguage(for: connection.type) != .sql || (services.databaseManager.driver(for: connectionId) as? PluginDriverAdapter)? .queryBuildingPluginDriver != nil diff --git a/TableProTests/Core/Utilities/SQL/QueryClassifierTests.swift b/TableProTests/Core/Utilities/SQL/QueryClassifierTests.swift new file mode 100644 index 000000000..39a3eb0cd --- /dev/null +++ b/TableProTests/Core/Utilities/SQL/QueryClassifierTests.swift @@ -0,0 +1,39 @@ +// +// QueryClassifierTests.swift +// TableProTests +// + +import Foundation +import Testing +@testable import TablePro + +@Suite("QueryClassifier isExplainStatement") +struct QueryClassifierExplainTests { + @Test("Detects EXPLAIN and EXPLAIN ANALYZE variants") + func detectsExplainVariants() { + #expect(QueryClassifier.isExplainStatement("EXPLAIN SELECT * FROM users")) + #expect(QueryClassifier.isExplainStatement("explain analyze select o.user_id from orders o")) + #expect(QueryClassifier.isExplainStatement("EXPLAIN ANALYZE SELECT 1")) + #expect(QueryClassifier.isExplainStatement("EXPLAIN FORMAT=JSON SELECT 1")) + #expect(QueryClassifier.isExplainStatement("EXPLAIN (ANALYZE, BUFFERS) SELECT 1")) + #expect(QueryClassifier.isExplainStatement("EXPLAIN(FORMAT JSON) SELECT 1")) + #expect(QueryClassifier.isExplainStatement("EXPLAIN QUERY PLAN SELECT 1")) + } + + @Test("Ignores leading whitespace and newlines after the keyword") + func handlesWhitespace() { + #expect(QueryClassifier.isExplainStatement(" EXPLAIN SELECT 1")) + #expect(QueryClassifier.isExplainStatement("\n\tEXPLAIN\nSELECT 1")) + } + + @Test("Does not match DESCRIBE, identifiers, or other statements") + func rejectsNonExplain() { + #expect(!QueryClassifier.isExplainStatement("DESCRIBE users")) + #expect(!QueryClassifier.isExplainStatement("DESC users")) + #expect(!QueryClassifier.isExplainStatement("SELECT * FROM explain_logs")) + #expect(!QueryClassifier.isExplainStatement("SELECT explain FROM t")) + #expect(!QueryClassifier.isExplainStatement("EXPLAINING SELECT 1")) + #expect(!QueryClassifier.isExplainStatement("EXPLAIN")) + #expect(!QueryClassifier.isExplainStatement("")) + } +} diff --git a/docs/features/explain-visualization.mdx b/docs/features/explain-visualization.mdx index 0349bd379..24b926087 100644 --- a/docs/features/explain-visualization.mdx +++ b/docs/features/explain-visualization.mdx @@ -9,6 +9,8 @@ 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. +Typing an `EXPLAIN`, `EXPLAIN ANALYZE`, or `EXPLAIN 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. + Date: Fri, 29 May 2026 23:04:43 +0700 Subject: [PATCH 2/2] refactor(datagrid): extract explain routing, detect MariaDB ANALYZE (#1480) --- .../QueryExecutionCoordinator+Helpers.swift | 13 +++--- .../Services/Query/ExplainResultRouter.swift | 15 +++++++ .../Core/Utilities/SQL/QueryClassifier.swift | 28 +++++++++++-- .../Query/ExplainResultRouterTests.swift | 40 +++++++++++++++++++ .../Utilities/SQL/QueryClassifierTests.swift | 12 +++++- docs/features/explain-visualization.mdx | 2 +- 6 files changed, 98 insertions(+), 12 deletions(-) create mode 100644 TablePro/Core/Services/Query/ExplainResultRouter.swift create mode 100644 TableProTests/Core/Services/Query/ExplainResultRouterTests.swift diff --git a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift index d741e78e0..07f4a058b 100644 --- a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift +++ b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift @@ -72,11 +72,12 @@ extension QueryExecutionCoordinator { ) { guard let idx = parent.tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { return } - if QueryClassifier.isExplainStatement(sql), columns.count == 1 { + if let planText = ExplainResultRouter.planText(sql: sql, columns: columns, rows: rows) { applyExplainResult( tabId: tabId, - rows: rows, + planText: planText, executionTime: executionTime, + rowCount: rows.count, sql: sql, connection: conn, queryParameterValues: queryParameterValues @@ -217,17 +218,19 @@ extension QueryExecutionCoordinator { private func applyExplainResult( tabId: UUID, - rows: [[PluginCellValue]], + planText: String, executionTime: TimeInterval, + rowCount: Int, sql: String, connection conn: DatabaseConnection, queryParameterValues: [QueryParameter]? ) { - let planText = rows.map { $0.first?.asText ?? "" }.joined(separator: "\n") 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.isExecuting = false tab.execution.lastExecutedAt = Date() tab.display.explainText = planText @@ -244,7 +247,7 @@ extension QueryExecutionCoordinator { connectionId: conn.id, databaseName: parent.activeDatabaseName, executionTime: executionTime, - rowCount: rows.count, + rowCount: rowCount, wasSuccessful: true, errorMessage: nil, parameterValues: queryParameterValues diff --git a/TablePro/Core/Services/Query/ExplainResultRouter.swift b/TablePro/Core/Services/Query/ExplainResultRouter.swift new file mode 100644 index 000000000..60a071e70 --- /dev/null +++ b/TablePro/Core/Services/Query/ExplainResultRouter.swift @@ -0,0 +1,15 @@ +// +// ExplainResultRouter.swift +// TablePro +// + +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 + } +} diff --git a/TablePro/Core/Utilities/SQL/QueryClassifier.swift b/TablePro/Core/Utilities/SQL/QueryClassifier.swift index 6d3c6972b..4bf166650 100644 --- a/TablePro/Core/Utilities/SQL/QueryClassifier.swift +++ b/TablePro/Core/Utilities/SQL/QueryClassifier.swift @@ -31,6 +31,8 @@ enum QueryClassifier { "FLUSHDB", "FLUSHALL", "DEBUG", "SHUTDOWN", ] + private static let explainPrefixes: [String] = ["EXPLAIN", "ANALYZE"] + private static let whereClauseRegex = try? NSRegularExpression(pattern: "\\sWHERE\\s", options: []) static func isWriteQuery(_ sql: String, databaseType: DatabaseType) -> Bool { @@ -132,10 +134,28 @@ enum QueryClassifier { } static func isExplainStatement(_ sql: String) -> Bool { - let upper = sql.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() - guard upper.hasPrefix("EXPLAIN"), let boundary = upper.dropFirst("EXPLAIN".count).first else { - return false + let upper = strippingLeadingComments(sql).uppercased() + return explainPrefixes.contains { prefix in + guard upper.hasPrefix(prefix), let boundary = upper.dropFirst(prefix.count).first else { + return false + } + return boundary == "(" || boundary.isWhitespace + } + } + + private static func strippingLeadingComments(_ sql: String) -> String { + var remaining = sql[...] + while true { + let trimmed = remaining.drop { $0.isWhitespace } + if trimmed.hasPrefix("--") { + guard let newline = trimmed.firstIndex(of: "\n") else { return "" } + remaining = trimmed[trimmed.index(after: newline)...] + } else if trimmed.hasPrefix("/*") { + guard let close = trimmed.range(of: "*/") else { return "" } + remaining = trimmed[close.upperBound...] + } else { + return String(trimmed) + } } - return boundary == "(" || boundary.isWhitespace } } diff --git a/TableProTests/Core/Services/Query/ExplainResultRouterTests.swift b/TableProTests/Core/Services/Query/ExplainResultRouterTests.swift new file mode 100644 index 000000000..b802d155c --- /dev/null +++ b/TableProTests/Core/Services/Query/ExplainResultRouterTests.swift @@ -0,0 +1,40 @@ +// +// ExplainResultRouterTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +import Testing +@testable import TablePro + +@Suite("ExplainResultRouter planText") +struct ExplainResultRouterTests { + @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") + } + + @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("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) + } + + @Test("Returns nil when the plan text is empty") + func rejectsEmptyPlan() { + #expect(ExplainResultRouter.planText(sql: "EXPLAIN SELECT 1", columns: ["EXPLAIN"], rows: []) == nil) + let blank: [[PluginCellValue]] = [[.null]] + #expect(ExplainResultRouter.planText(sql: "EXPLAIN SELECT 1", columns: ["EXPLAIN"], rows: blank) == nil) + } +} diff --git a/TableProTests/Core/Utilities/SQL/QueryClassifierTests.swift b/TableProTests/Core/Utilities/SQL/QueryClassifierTests.swift index 39a3eb0cd..2b45c449e 100644 --- a/TableProTests/Core/Utilities/SQL/QueryClassifierTests.swift +++ b/TableProTests/Core/Utilities/SQL/QueryClassifierTests.swift @@ -20,10 +20,18 @@ struct QueryClassifierExplainTests { #expect(QueryClassifier.isExplainStatement("EXPLAIN QUERY PLAN SELECT 1")) } - @Test("Ignores leading whitespace and newlines after the keyword") - func handlesWhitespace() { + @Test("Detects MariaDB ANALYZE statements") + func detectsAnalyzeVariants() { + #expect(QueryClassifier.isExplainStatement("ANALYZE FORMAT=JSON SELECT 1")) + #expect(QueryClassifier.isExplainStatement("analyze select 1")) + } + + @Test("Ignores leading whitespace, newlines, and comments") + func handlesWhitespaceAndComments() { #expect(QueryClassifier.isExplainStatement(" EXPLAIN SELECT 1")) #expect(QueryClassifier.isExplainStatement("\n\tEXPLAIN\nSELECT 1")) + #expect(QueryClassifier.isExplainStatement("-- plan check\nEXPLAIN SELECT 1")) + #expect(QueryClassifier.isExplainStatement("/* warm cache */ EXPLAIN ANALYZE SELECT 1")) } @Test("Does not match DESCRIBE, identifiers, or other statements") diff --git a/docs/features/explain-visualization.mdx b/docs/features/explain-visualization.mdx index 24b926087..9dc7460d1 100644 --- a/docs/features/explain-visualization.mdx +++ b/docs/features/explain-visualization.mdx @@ -9,7 +9,7 @@ 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. -Typing an `EXPLAIN`, `EXPLAIN ANALYZE`, or `EXPLAIN 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, 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.