diff --git a/CHANGELOG.md b/CHANGELOG.md
index 71381d6e3..4737d8292 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -29,6 +29,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)
- Filtering the data grid keeps you on the keyboard. Applying or clearing a filter returns focus to the grid so you can keep moving through cells, Return applies the filter, and Escape closes the filter panel and returns to the grid. (#1490)
- 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..07f4a058b 100644
--- a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift
+++ b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift
@@ -72,6 +72,19 @@ 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) {
+ applyExplainResult(
+ tabId: tabId,
+ planText: planText,
+ executionTime: executionTime,
+ rowCount: rows.count,
+ sql: sql,
+ connection: conn,
+ queryParameterValues: queryParameterValues
+ )
+ return
+ }
+
let existingTabId = parent.tabManager.tabs[idx].id
var columnEnumValues: [String: [String]] = [:]
var columnDefaults: [String: String?] = [:]
@@ -203,6 +216,44 @@ extension QueryExecutionCoordinator {
}
}
+ private func applyExplainResult(
+ tabId: UUID,
+ planText: String,
+ 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.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: rowCount,
+ wasSuccessful: true,
+ errorMessage: nil,
+ parameterValues: queryParameterValues
+ )
+ }
+
private func applyDefaultSortIfPending(
tabId: UUID,
tabIndex: Int,
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 78a4f1425..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 {
@@ -130,4 +132,30 @@ enum QueryClassifier {
static func isMultiStatement(_ sql: String) -> Bool {
SQLStatementScanner.allStatements(in: sql).count > 1
}
+
+ static func isExplainStatement(_ sql: String) -> Bool {
+ 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)
+ }
+ }
+ }
}
diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift
index 9402f8100..cde7a4156 100644
--- a/TablePro/Views/Main/MainContentCoordinator.swift
+++ b/TablePro/Views/Main/MainContentCoordinator.swift
@@ -1150,6 +1150,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/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
new file mode 100644
index 000000000..2b45c449e
--- /dev/null
+++ b/TableProTests/Core/Utilities/SQL/QueryClassifierTests.swift
@@ -0,0 +1,47 @@
+//
+// 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("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")
+ 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..9dc7460d1 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`, `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.
+
![]()