Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
51 changes: 51 additions & 0 deletions TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?] = [:]
Expand Down Expand Up @@ -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,
Expand Down
15 changes: 15 additions & 0 deletions TablePro/Core/Services/Query/ExplainResultRouter.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
28 changes: 28 additions & 0 deletions TablePro/Core/Utilities/SQL/QueryClassifier.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
}
}
}
3 changes: 3 additions & 0 deletions TablePro/Views/Main/MainContentCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
40 changes: 40 additions & 0 deletions TableProTests/Core/Services/Query/ExplainResultRouterTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
47 changes: 47 additions & 0 deletions TableProTests/Core/Utilities/SQL/QueryClassifierTests.swift
Original file line number Diff line number Diff line change
@@ -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(""))
}
}
2 changes: 2 additions & 0 deletions docs/features/explain-visualization.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<Frame caption="EXPLAIN diagram view">
<img
className="block dark:hidden"
Expand Down
Loading