Skip to content
Closed
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- MySQL `EXPLAIN FORMAT=TREE` and `EXPLAIN ANALYZE` output now renders as a visual plan diagram or tree instead of raw text only.
- EXPLAIN plan diagrams now support trackpad pinch-to-zoom in addition to the zoom buttons.
- PostgreSQL array columns of a simple type, including arrays of an enum, get a list editor in the data grid. One row per element, with reordering, add and remove, and NULL per element. An empty array and a NULL column stay separate values. Enum arrays pick from the labels the type declares. Arrays of `jsonb`, `bytea` or composite types, and multi-dimensional values, keep the plain text editor.

### Fixed
Expand Down
298 changes: 295 additions & 3 deletions TablePro/Core/Services/Query/QueryPlanParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -83,14 +83,24 @@ struct PostgreSQLPlanParser: QueryPlanParser {
}
}

// MARK: - MySQL JSON Parser
// MARK: - MySQL Parsers

struct MySQLPlanParser: QueryPlanParser {
func parse(rawText: String) -> QueryPlan? {
MySQLJsonPlanParser().parse(rawText: rawText)
?? MySQLTreePlanParser().parse(rawText: rawText)
}
}

/// 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 {
struct MySQLJsonPlanParser: QueryPlanParser {
func parse(rawText: String) -> QueryPlan? {
guard let data = rawText.data(using: .utf8),
let trimmed = rawText.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmed.first == "{" else { return nil }

guard let data = trimmed.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let queryBlock = json["query_block"] as? [String: Any]
else {
Expand Down Expand Up @@ -205,6 +215,288 @@ struct MySQLPlanParser: QueryPlanParser {
}
}

struct MySQLTreePlanParser: QueryPlanParser {
private struct RawNode {
let depth: Int
let text: String
}

private struct ParsedMetrics {
var estimatedCost: Double?
var estimatedRows: Int?
var actualStartupTime: Double?
var actualTotalTime: Double?
var actualRows: Int?
var actualLoops: Int?
var wasExecuted = true
}

private struct ParsedNodeText {
let operation: String
let relation: String?
let properties: [String: String]
let metrics: ParsedMetrics
}

private static let maximumInputBytes = 2_000_000
private static let maximumNodes = 10_000
private static let maximumDepth = 128
private static let numberPattern = "[-+]?(?:\\d+(?:\\.\\d*)?|\\.\\d+)(?:[eE][-+]?\\d+)?"
private static let actualMetricsRegex = try? NSRegularExpression(
pattern: "\\s+\\(actual time=(\(numberPattern))\\.\\.(\(numberPattern))"
+ "\\s+rows=(\(numberPattern))\\s+loops=(\(numberPattern))\\)\\s*$"
)
private static let costMetricsRegex = try? NSRegularExpression(
pattern: "\\s+\\(cost=(\(numberPattern))\\s+rows=(\(numberPattern))\\)\\s*$"
)
private static let neverExecutedRegex = try? NSRegularExpression(
pattern: "\\s+\\(never executed\\)\\s*$",
options: [.caseInsensitive]
)

func parse(rawText: String) -> QueryPlan? {
guard rawText.utf8.count <= Self.maximumInputBytes else {
logger.debug("MySQL TREE plan exceeds parser input limit")
return nil
}

guard let rawNodes = parseRawNodes(rawText), !rawNodes.isEmpty else { return nil }

var index = 0
func build(parentDepth: Int) -> [QueryPlanNode] {
var nodes: [QueryPlanNode] = []
while index < rawNodes.count {
let rawNode = rawNodes[index]
if rawNode.depth <= parentDepth { break }
index += 1
let parsed = Self.parseNodeText(rawNode.text)
let children = build(parentDepth: rawNode.depth)
nodes.append(QueryPlanNode(
operation: parsed.operation,
relation: parsed.relation,
schema: nil,
alias: nil,
estimatedStartupCost: nil,
estimatedTotalCost: parsed.metrics.estimatedCost,
estimatedRows: parsed.metrics.estimatedRows,
estimatedWidth: nil,
actualStartupTime: parsed.metrics.actualStartupTime,
actualTotalTime: parsed.metrics.actualTotalTime,
actualRows: parsed.metrics.actualRows,
actualLoops: parsed.metrics.actualLoops,
properties: parsed.properties,
children: children
))
}
return nodes
}

let roots = build(parentDepth: -1)
guard !roots.isEmpty else { return nil }

let rootNode: QueryPlanNode
if roots.count == 1 {
rootNode = roots[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: roots
)
}

let executionTime = roots.compactMap(Self.totalExecutionTime).max()
var plan = QueryPlan(
rootNode: rootNode,
planningTime: nil,
executionTime: executionTime,
rawText: rawText
)
plan.computeCostFractions()
return plan
}

private func parseRawNodes(_ rawText: String) -> [RawNode]? {
var nodes: [RawNode] = []
var indentationStack: [Int] = []
var pending: (indent: Int, text: String)?

func appendPending() -> Bool {
guard let pending else { return true }
while let lastIndent = indentationStack.last, pending.indent <= lastIndent {
indentationStack.removeLast()
}
guard indentationStack.count < Self.maximumDepth,
nodes.count < Self.maximumNodes else { return false }
indentationStack.append(pending.indent)
nodes.append(RawNode(depth: indentationStack.count - 1, text: pending.text))
return true
}

for line in rawText.components(separatedBy: .newlines) {
if let nodeLine = Self.nodeLine(from: line) {
guard appendPending() else { return nil }
pending = nodeLine
continue
}

let continuation = line.trimmingCharacters(in: .whitespacesAndNewlines)
if var current = pending, Self.isMetricContinuation(continuation) {
current.text += " \(continuation)"
pending = current
}
}

guard appendPending() else { return nil }
return nodes
}

private static func nodeLine(from line: String) -> (indent: Int, text: String)? {
let leadingWhitespace = line.prefix { $0 == " " || $0 == "\t" }
let remainder = line.dropFirst(leadingWhitespace.count)
guard remainder.hasPrefix("->") else { return nil }

let text = remainder.dropFirst(2).trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return nil }

let indent = leadingWhitespace.reduce(into: 0) { width, character in
width += character == "\t" ? 4 : 1
}
return (indent, text)
}

private static func isMetricContinuation(_ line: String) -> Bool {
guard line.first == "(" else { return false }
let lowercased = line.lowercased()
return lowercased.hasPrefix("(cost=")
|| lowercased.hasPrefix("(actual time=")
|| lowercased == "(never executed)"
}

private static func parseNodeText(_ rawText: String) -> ParsedNodeText {
var text = rawText.trimmingCharacters(in: .whitespacesAndNewlines)
var metrics = ParsedMetrics()

if let match = match(Self.actualMetricsRegex, in: text), match.numberOfRanges == 5 {
metrics.actualStartupTime = nonnegativeDouble(capture(1, from: match, in: text))
metrics.actualTotalTime = nonnegativeDouble(capture(2, from: match, in: text))
metrics.actualRows = roundedInt(capture(3, from: match, in: text))
metrics.actualLoops = wholeInt(capture(4, from: match, in: text))
text = removing(match, from: text)
} else if let match = match(Self.neverExecutedRegex, in: text) {
metrics.wasExecuted = false
text = removing(match, from: text)
}

if let match = match(Self.costMetricsRegex, in: text), match.numberOfRanges == 3 {
metrics.estimatedCost = nonnegativeDouble(capture(1, from: match, in: text))
metrics.estimatedRows = roundedInt(capture(2, from: match, in: text))
text = removing(match, from: text)
}

let presentation = accessPresentation(from: text)
var properties = presentation.properties
if !metrics.wasExecuted {
properties["Execution"] = "Never executed"
}
return ParsedNodeText(
operation: presentation.operation,
relation: presentation.relation,
properties: properties,
metrics: metrics
)
}

private static func accessPresentation(
from text: String
) -> (operation: String, relation: String?, properties: [String: String]) {
guard let onRange = text.range(of: " on ", options: [.caseInsensitive]),
isAccessOperation(String(text[..<onRange.lowerBound])) else {
return (text, nil, [:])
}

let operation = text[..<onRange.lowerBound].trimmingCharacters(in: .whitespaces)
let remainder = String(text[onRange.upperBound...])
guard let relation = leadingIdentifier(in: remainder) else { return (text, nil, [:]) }

var properties = ["Details": text]
let suffix = remainder.dropFirst(relation.count)
if let usingRange = suffix.range(of: "using ", options: [.caseInsensitive]),
let indexName = leadingIdentifier(in: String(suffix[usingRange.upperBound...])) {
properties["Index Name"] = indexName
}
return (operation, relation, properties)
}

private static func isAccessOperation(_ operation: String) -> Bool {
let lowercased = operation.lowercased()
return lowercased.contains("scan") || lowercased.contains("lookup") || lowercased.contains("search")
}

private static func leadingIdentifier(in text: String) -> String? {
let trimmed = text.trimmingCharacters(in: .whitespaces)
guard let first = trimmed.first else { return nil }

let closingCharacter: Character?
switch first {
case "`": closingCharacter = "`"
case "<": closingCharacter = ">"
default: closingCharacter = nil
}

if let closingCharacter,
let end = trimmed.dropFirst().firstIndex(of: closingCharacter) {
return String(trimmed[...end])
}
let identifier = String(trimmed.prefix { !$0.isWhitespace && $0 != "(" })
return identifier.isEmpty ? nil : identifier
}

private static func match(_ regex: NSRegularExpression?, in text: String) -> NSTextCheckingResult? {
guard let regex else { return nil }
return regex.firstMatch(in: text, range: NSRange(text.startIndex..., in: text))
}

private static func capture(_ index: Int, from match: NSTextCheckingResult, in text: String) -> String? {
guard let range = Range(match.range(at: index), in: text) else { return nil }
return String(text[range])
}

private static func removing(_ match: NSTextCheckingResult, from text: String) -> String {
guard let range = Range(match.range, in: text) else { return text }
var result = text
result.removeSubrange(range)
return result.trimmingCharacters(in: .whitespacesAndNewlines)
}

private static func nonnegativeDouble(_ text: String?) -> Double? {
guard let text, let value = Double(text), value.isFinite, value >= 0 else { return nil }
return value
}

private static func roundedInt(_ text: String?) -> Int? {
guard let value = nonnegativeDouble(text), value < Double(Int.max) else { return nil }
return Int(value.rounded())
}

private static func wholeInt(_ text: String?) -> Int? {
guard let value = nonnegativeDouble(text), value.rounded() == value,
value < Double(Int.max) else { return nil }
return Int(value)
}

private static func totalExecutionTime(_ node: QueryPlanNode) -> Double? {
guard let time = node.actualTotalTime else { return nil }
let total = time * Double(node.actualLoops ?? 1)
return total.isFinite ? total : nil
}
}

// MARK: - SQLite Parser

/// Parses SQLite `EXPLAIN QUERY PLAN` output (id/parent/notused/detail columns).
Expand Down
Loading
Loading