Skip to content

Commit 596f8a1

Browse files
authored
fix(datagrid): show typed EXPLAIN results in the plan viewer (#1480) (#1488)
* fix(datagrid): show typed EXPLAIN results in the plan viewer (#1480) * refactor(datagrid): extract explain routing, detect MariaDB ANALYZE (#1480) --------- Signed-off-by: Ngô Quốc Đạt <datlechin@gmail.com>
1 parent 8762395 commit 596f8a1

8 files changed

Lines changed: 187 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2929

3030
### Fixed
3131

32+
- 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)
3233
- 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)
3334
- Moving a connection into or out of a group now syncs across devices, instead of leaving it ungrouped on your other Macs.
3435
- 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)

TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,19 @@ extension QueryExecutionCoordinator {
7272
) {
7373
guard let idx = parent.tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { return }
7474

75+
if let planText = ExplainResultRouter.planText(sql: sql, columns: columns, rows: rows) {
76+
applyExplainResult(
77+
tabId: tabId,
78+
planText: planText,
79+
executionTime: executionTime,
80+
rowCount: rows.count,
81+
sql: sql,
82+
connection: conn,
83+
queryParameterValues: queryParameterValues
84+
)
85+
return
86+
}
87+
7588
let existingTabId = parent.tabManager.tabs[idx].id
7689
var columnEnumValues: [String: [String]] = [:]
7790
var columnDefaults: [String: String?] = [:]
@@ -203,6 +216,44 @@ extension QueryExecutionCoordinator {
203216
}
204217
}
205218

219+
private func applyExplainResult(
220+
tabId: UUID,
221+
planText: String,
222+
executionTime: TimeInterval,
223+
rowCount: Int,
224+
sql: String,
225+
connection conn: DatabaseConnection,
226+
queryParameterValues: [QueryParameter]?
227+
) {
228+
let plan = QueryPlanParserFactory.parser(for: conn.type)?.parse(rawText: planText)
229+
230+
parent.tabManager.mutate(tabId: tabId) { tab in
231+
tab.execution.executionTime = executionTime
232+
tab.execution.rowsAffected = 0
233+
tab.execution.statusMessage = nil
234+
tab.execution.isExecuting = false
235+
tab.execution.lastExecutedAt = Date()
236+
tab.display.explainText = planText
237+
tab.display.explainPlan = plan
238+
tab.display.explainExecutionTime = executionTime
239+
if tab.display.isResultsCollapsed {
240+
tab.display.isResultsCollapsed = false
241+
}
242+
}
243+
parent.toolbarState.isResultsCollapsed = false
244+
245+
QueryHistoryManager.shared.recordQuery(
246+
query: sql,
247+
connectionId: conn.id,
248+
databaseName: parent.activeDatabaseName,
249+
executionTime: executionTime,
250+
rowCount: rowCount,
251+
wasSuccessful: true,
252+
errorMessage: nil,
253+
parameterValues: queryParameterValues
254+
)
255+
}
256+
206257
private func applyDefaultSortIfPending(
207258
tabId: UUID,
208259
tabIndex: Int,
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
//
2+
// ExplainResultRouter.swift
3+
// TablePro
4+
//
5+
6+
import Foundation
7+
import TableProPluginKit
8+
9+
enum ExplainResultRouter {
10+
static func planText(sql: String, columns: [String], rows: [[PluginCellValue]]) -> String? {
11+
guard QueryClassifier.isExplainStatement(sql), columns.count == 1 else { return nil }
12+
let text = rows.map { $0.first?.asText ?? "" }.joined(separator: "\n")
13+
return text.isEmpty ? nil : text
14+
}
15+
}

TablePro/Core/Utilities/SQL/QueryClassifier.swift

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ enum QueryClassifier {
3131
"FLUSHDB", "FLUSHALL", "DEBUG", "SHUTDOWN",
3232
]
3333

34+
private static let explainPrefixes: [String] = ["EXPLAIN", "ANALYZE"]
35+
3436
private static let whereClauseRegex = try? NSRegularExpression(pattern: "\\sWHERE\\s", options: [])
3537

3638
static func isWriteQuery(_ sql: String, databaseType: DatabaseType) -> Bool {
@@ -130,4 +132,30 @@ enum QueryClassifier {
130132
static func isMultiStatement(_ sql: String) -> Bool {
131133
SQLStatementScanner.allStatements(in: sql).count > 1
132134
}
135+
136+
static func isExplainStatement(_ sql: String) -> Bool {
137+
let upper = strippingLeadingComments(sql).uppercased()
138+
return explainPrefixes.contains { prefix in
139+
guard upper.hasPrefix(prefix), let boundary = upper.dropFirst(prefix.count).first else {
140+
return false
141+
}
142+
return boundary == "(" || boundary.isWhitespace
143+
}
144+
}
145+
146+
private static func strippingLeadingComments(_ sql: String) -> String {
147+
var remaining = sql[...]
148+
while true {
149+
let trimmed = remaining.drop { $0.isWhitespace }
150+
if trimmed.hasPrefix("--") {
151+
guard let newline = trimmed.firstIndex(of: "\n") else { return "" }
152+
remaining = trimmed[trimmed.index(after: newline)...]
153+
} else if trimmed.hasPrefix("/*") {
154+
guard let close = trimmed.range(of: "*/") else { return "" }
155+
remaining = trimmed[close.upperBound...]
156+
} else {
157+
return String(trimmed)
158+
}
159+
}
160+
}
133161
}

TablePro/Views/Main/MainContentCoordinator.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1150,6 +1150,9 @@ final class MainContentCoordinator {
11501150
}
11511151

11521152
internal func resolveTableEditability(tab: QueryTab, sql: String) -> (tableName: String?, isEditable: Bool) {
1153+
if tab.tabType != .table, QueryClassifier.isExplainStatement(sql) {
1154+
return (nil, false)
1155+
}
11531156
let usesNoSQLBrowsing = services.pluginManager.editorLanguage(for: connection.type) != .sql
11541157
|| (services.databaseManager.driver(for: connectionId) as? PluginDriverAdapter)?
11551158
.queryBuildingPluginDriver != nil
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
//
2+
// ExplainResultRouterTests.swift
3+
// TableProTests
4+
//
5+
6+
import Foundation
7+
import TableProPluginKit
8+
import Testing
9+
@testable import TablePro
10+
11+
@Suite("ExplainResultRouter planText")
12+
struct ExplainResultRouterTests {
13+
@Test("Joins single-column explain rows with newlines")
14+
func joinsSingleColumnRows() {
15+
let rows: [[PluginCellValue]] = [[.text("-> Limit: 5 row(s)")], [.text(" -> Sort")]]
16+
let result = ExplainResultRouter.planText(sql: "EXPLAIN ANALYZE SELECT 1", columns: ["EXPLAIN"], rows: rows)
17+
#expect(result == "-> Limit: 5 row(s)\n -> Sort")
18+
}
19+
20+
@Test("Returns nil for multi-column explain results")
21+
func rejectsMultiColumn() {
22+
let rows: [[PluginCellValue]] = [[.text("1"), .text("SIMPLE")]]
23+
let result = ExplainResultRouter.planText(sql: "EXPLAIN SELECT 1", columns: ["id", "select_type"], rows: rows)
24+
#expect(result == nil)
25+
}
26+
27+
@Test("Returns nil for non-explain statements")
28+
func rejectsNonExplain() {
29+
let rows: [[PluginCellValue]] = [[.text("value")]]
30+
let result = ExplainResultRouter.planText(sql: "SELECT col FROM t", columns: ["col"], rows: rows)
31+
#expect(result == nil)
32+
}
33+
34+
@Test("Returns nil when the plan text is empty")
35+
func rejectsEmptyPlan() {
36+
#expect(ExplainResultRouter.planText(sql: "EXPLAIN SELECT 1", columns: ["EXPLAIN"], rows: []) == nil)
37+
let blank: [[PluginCellValue]] = [[.null]]
38+
#expect(ExplainResultRouter.planText(sql: "EXPLAIN SELECT 1", columns: ["EXPLAIN"], rows: blank) == nil)
39+
}
40+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
//
2+
// QueryClassifierTests.swift
3+
// TableProTests
4+
//
5+
6+
import Foundation
7+
import Testing
8+
@testable import TablePro
9+
10+
@Suite("QueryClassifier isExplainStatement")
11+
struct QueryClassifierExplainTests {
12+
@Test("Detects EXPLAIN and EXPLAIN ANALYZE variants")
13+
func detectsExplainVariants() {
14+
#expect(QueryClassifier.isExplainStatement("EXPLAIN SELECT * FROM users"))
15+
#expect(QueryClassifier.isExplainStatement("explain analyze select o.user_id from orders o"))
16+
#expect(QueryClassifier.isExplainStatement("EXPLAIN ANALYZE SELECT 1"))
17+
#expect(QueryClassifier.isExplainStatement("EXPLAIN FORMAT=JSON SELECT 1"))
18+
#expect(QueryClassifier.isExplainStatement("EXPLAIN (ANALYZE, BUFFERS) SELECT 1"))
19+
#expect(QueryClassifier.isExplainStatement("EXPLAIN(FORMAT JSON) SELECT 1"))
20+
#expect(QueryClassifier.isExplainStatement("EXPLAIN QUERY PLAN SELECT 1"))
21+
}
22+
23+
@Test("Detects MariaDB ANALYZE statements")
24+
func detectsAnalyzeVariants() {
25+
#expect(QueryClassifier.isExplainStatement("ANALYZE FORMAT=JSON SELECT 1"))
26+
#expect(QueryClassifier.isExplainStatement("analyze select 1"))
27+
}
28+
29+
@Test("Ignores leading whitespace, newlines, and comments")
30+
func handlesWhitespaceAndComments() {
31+
#expect(QueryClassifier.isExplainStatement(" EXPLAIN SELECT 1"))
32+
#expect(QueryClassifier.isExplainStatement("\n\tEXPLAIN\nSELECT 1"))
33+
#expect(QueryClassifier.isExplainStatement("-- plan check\nEXPLAIN SELECT 1"))
34+
#expect(QueryClassifier.isExplainStatement("/* warm cache */ EXPLAIN ANALYZE SELECT 1"))
35+
}
36+
37+
@Test("Does not match DESCRIBE, identifiers, or other statements")
38+
func rejectsNonExplain() {
39+
#expect(!QueryClassifier.isExplainStatement("DESCRIBE users"))
40+
#expect(!QueryClassifier.isExplainStatement("DESC users"))
41+
#expect(!QueryClassifier.isExplainStatement("SELECT * FROM explain_logs"))
42+
#expect(!QueryClassifier.isExplainStatement("SELECT explain FROM t"))
43+
#expect(!QueryClassifier.isExplainStatement("EXPLAINING SELECT 1"))
44+
#expect(!QueryClassifier.isExplainStatement("EXPLAIN"))
45+
#expect(!QueryClassifier.isExplainStatement(""))
46+
}
47+
}

docs/features/explain-visualization.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ Click **Explain** in the query editor toolbar to get the execution plan.
99

1010
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.
1111

12+
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.
13+
1214
<Frame caption="EXPLAIN diagram view">
1315
<img
1416
className="block dark:hidden"

0 commit comments

Comments
 (0)