diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ac0b3477..f6c8d85a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/TablePro/Core/Services/Query/QueryPlanParser.swift b/TablePro/Core/Services/Query/QueryPlanParser.swift index d2acc3bfb..03f2bca6f 100644 --- a/TablePro/Core/Services/Query/QueryPlanParser.swift +++ b/TablePro/Core/Services/Query/QueryPlanParser.swift @@ -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 { @@ -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[.. 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). diff --git a/TablePro/Views/QueryPlan/QueryPlanDiagramView.swift b/TablePro/Views/QueryPlan/QueryPlanDiagramView.swift index 3257449f9..d9f516a82 100644 --- a/TablePro/Views/QueryPlan/QueryPlanDiagramView.swift +++ b/TablePro/Views/QueryPlan/QueryPlanDiagramView.swift @@ -28,6 +28,27 @@ private struct PositionedNode: Identifiable { let parentId: UUID? } +enum QueryPlanDiagramZoom { + static let minimum: CGFloat = 0.25 + static let maximum: CGFloat = 3.0 + static let step: CGFloat = 0.25 + + static func clamped(_ value: CGFloat) -> CGFloat { + if value.isNaN { return 1.0 } + if value == .infinity { return maximum } + if value == -.infinity { return minimum } + return min(maximum, max(minimum, value)) + } + + static func scaled(from startingMagnification: CGFloat, by gestureMagnification: CGFloat) -> CGFloat { + let startingMagnification = clamped(startingMagnification) + guard gestureMagnification.isFinite, gestureMagnification > 0 else { + return startingMagnification + } + return clamped(startingMagnification * gestureMagnification) + } +} + // MARK: - Diagram View struct QueryPlanDiagramView: View { @@ -37,6 +58,7 @@ struct QueryPlanDiagramView: View { @State private var selectedNode: SelectedNodeID? @State private var positioned: [PositionedNode] = [] @State private var canvasSize = CGSize(width: 400, height: 300) + @State private var magnifyStartMagnification: CGFloat? var body: some View { ZStack(alignment: .bottomTrailing) { @@ -58,7 +80,7 @@ struct QueryPlanDiagramView: View { } } .frame(width: canvasSize.width, height: canvasSize.height) - .scaleEffect(magnification) + .scaleEffect(magnification, anchor: .topLeading) .frame( width: canvasSize.width * magnification, height: canvasSize.height * magnification, @@ -69,6 +91,7 @@ struct QueryPlanDiagramView: View { zoomControls .padding(12) } + .simultaneousGesture(magnifyGesture) .onAppear { let nodes = layoutNodes(plan.rootNode, depth: 0, xOffset: 0, parentId: nil) positioned = nodes @@ -139,21 +162,41 @@ struct QueryPlanDiagramView: View { // MARK: - Zoom + private var magnifyGesture: some Gesture { + MagnifyGesture() + .onChanged { value in + if magnifyStartMagnification == nil { + magnifyStartMagnification = magnification + } + magnification = QueryPlanDiagramZoom.scaled( + from: magnifyStartMagnification ?? magnification, + by: value.magnification + ) + } + .onEnded { _ in + magnifyStartMagnification = nil + } + } + private var zoomControls: some View { HStack(spacing: 4) { - Button { magnification = max(0.25, magnification - 0.25) } label: { + Button { + magnification = QueryPlanDiagramZoom.clamped(magnification - QueryPlanDiagramZoom.step) + } label: { Image(systemName: "minus.magnifyingglass") .frame(width: 24, height: 24) } .accessibilityLabel(String(localized: "Zoom out")) .help(String(localized: "Zoom out")) - Text("\(Int(magnification * 100))%") + Text("\(Int((magnification * 100).rounded()))%") .font(.caption) .foregroundStyle(.secondary) .frame(width: 36) - Button { magnification = min(3.0, magnification + 0.25) } label: { + Button { + magnification = QueryPlanDiagramZoom.clamped(magnification + QueryPlanDiagramZoom.step) + } label: { Image(systemName: "plus.magnifyingglass") .frame(width: 24, height: 24) } diff --git a/TableProTests/Core/Services/Query/MySQLPlanParserTests.swift b/TableProTests/Core/Services/Query/MySQLPlanParserTests.swift new file mode 100644 index 000000000..e84c39151 --- /dev/null +++ b/TableProTests/Core/Services/Query/MySQLPlanParserTests.swift @@ -0,0 +1,155 @@ +import Foundation +@testable import TablePro +import Testing + +@Suite("MySQL Plan Parser") +struct MySQLPlanParserTests { + private let parser = MySQLPlanParser() + + private let analyzeOutput = [ + "-> Inner hash join (t2.c2 = t1.c1) (cost=3.5 rows=5) (actual time=0.121..0.131 rows=1 loops=1)", + " -> Table scan on t2 (cost=0.07 rows=5) (actual time=0.0126..0.0221 rows=5 loops=1)", + " -> Hash", + " -> Table scan on t1 (cost=0.75 rows=5) (actual time=0.0372..0.0534 rows=5 loops=1)", + ].joined(separator: "\n") + + @Test("Parses TREE hierarchy and ANALYZE metrics") + func parsesTreeHierarchyAndMetrics() throws { + let plan = try #require(parser.parse(rawText: analyzeOutput)) + + #expect(plan.rootNode.operation == "Inner hash join (t2.c2 = t1.c1)") + #expect(plan.rootNode.estimatedTotalCost == 3.5) + #expect(plan.rootNode.estimatedRows == 5) + #expect(plan.rootNode.actualStartupTime == 0.121) + #expect(plan.rootNode.actualTotalTime == 0.131) + #expect(plan.rootNode.actualRows == 1) + #expect(plan.rootNode.actualLoops == 1) + #expect(plan.executionTime == 0.131) + #expect(plan.rootNode.children.count == 2) + + let tableScan = plan.rootNode.children[0] + #expect(tableScan.operation == "Table scan") + #expect(tableScan.relation == "t2") + + let hash = plan.rootNode.children[1] + #expect(hash.operation == "Hash") + #expect(hash.children.count == 1) + #expect(hash.children[0].relation == "t1") + } + + @Test("Extracts index details from access nodes") + func extractsIndexDetails() throws { + let output = """ + -> Index range scan on `orders archive` using `created_at_idx` over (created_at > 1) \ + (cost=12.5 rows=42) + """ + + let plan = try #require(parser.parse(rawText: output)) + #expect(plan.rootNode.operation == "Index range scan") + #expect(plan.rootNode.relation == "`orders archive`") + #expect(plan.rootNode.properties["Index Name"] == "`created_at_idx`") + #expect(plan.rootNode.properties["Details"]?.contains("created_at > 1") == true) + } + + @Test("Accepts engineering notation and averages across loops") + func acceptsEngineeringNotation() throws { + let output = """ + -> Table scan on huge_table (cost=1.23e+9 rows=934e+6) \ + (actual time=934e-6..1.2e+6 rows=1.23e+3 loops=2) + """ + + let plan = try #require(parser.parse(rawText: output)) + #expect(plan.rootNode.estimatedTotalCost == 1.23e+9) + #expect(plan.rootNode.estimatedRows == 934_000_000) + #expect(plan.rootNode.actualStartupTime == 934e-6) + #expect(plan.rootNode.actualTotalTime == 1.2e+6) + #expect(plan.rootNode.actualRows == 1_230) + #expect(plan.rootNode.actualLoops == 2) + #expect(plan.executionTime == 2.4e+6) + } + + @Test("Joins wrapped metric lines") + func joinsWrappedMetrics() throws { + let output = [ + "-> Filter: (users.active = true)", + " (cost=4.2 rows=8)", + " (actual time=0.01..0.03 rows=7 loops=1)", + " -> Table scan on users (cost=3 rows=10)", + ].joined(separator: "\r\n") + + let plan = try #require(parser.parse(rawText: output)) + #expect(plan.rootNode.estimatedTotalCost == 4.2) + #expect(plan.rootNode.actualRows == 7) + #expect(plan.rootNode.children.first?.relation == "users") + } + + @Test("Keeps nodes that were never executed") + func keepsNeverExecutedNodes() throws { + let output = [ + "-> Nested loop inner join (cost=5 rows=1)", + " -> Table scan on users (cost=2 rows=1)", + " -> Index lookup on orders using user_id_idx (cost=3 rows=1) (never executed)", + ].joined(separator: "\n") + + let plan = try #require(parser.parse(rawText: output)) + let skipped = plan.rootNode.children[1] + #expect(skipped.operation == "Index lookup") + #expect(skipped.actualTotalTime == nil) + #expect(skipped.properties["Execution"] == "Never executed") + } + + @Test("Falls back to the existing JSON parser") + func parsesJSONPlan() throws { + let output = """ + { + "query_block": { + "cost_info": { "query_cost": "1.25" }, + "table": { + "table_name": "users", + "access_type": "ALL", + "rows_examined_per_scan": 10 + } + } + } + """ + + let plan = try #require(parser.parse(rawText: output)) + #expect(plan.rootNode.operation == "Query Block") + #expect(plan.rootNode.estimatedTotalCost == 1.25) + #expect(plan.rootNode.children.first?.relation == "users") + #expect(plan.rootNode.children.first?.estimatedRows == 10) + } + + @Test("Wraps multiple top-level iterators in one synthetic root") + func wrapsMultipleRoots() throws { + let output = [ + "-> Table scan on users (cost=1 rows=1)", + "-> Table scan on roles (cost=1 rows=1)", + ].joined(separator: "\n") + + let plan = try #require(parser.parse(rawText: output)) + #expect(plan.rootNode.operation == "Query Plan") + #expect(plan.rootNode.children.count == 2) + } + + @Test("Rejects malformed, oversized, and excessively deep input") + func rejectsUnsafeInput() { + #expect(parser.parse(rawText: "") == nil) + #expect(parser.parse(rawText: "not a TREE plan") == nil) + #expect(parser.parse(rawText: String(repeating: "x", count: 2_000_001)) == nil) + + let overflowingTime = "-> Scan (actual time=1..1e308 rows=1 loops=1e18)" + #expect(parser.parse(rawText: overflowingTime)?.executionTime == nil) + + let excessiveDepth = (0..<130) + .map { String(repeating: " ", count: $0) + "-> Node \($0)" } + .joined(separator: "\n") + #expect(parser.parse(rawText: excessiveDepth) == nil) + } + + @Test("Factory uses the composite parser for MySQL and MariaDB") + func factoryUsesCompositeParser() { + #expect(QueryPlanParserFactory.parser(for: .mysql) is MySQLPlanParser) + #expect(QueryPlanParserFactory.parser(for: .mariadb) is MySQLPlanParser) + } +} diff --git a/TableProTests/Views/QueryPlan/QueryPlanDiagramZoomTests.swift b/TableProTests/Views/QueryPlan/QueryPlanDiagramZoomTests.swift new file mode 100644 index 000000000..69557d45b --- /dev/null +++ b/TableProTests/Views/QueryPlan/QueryPlanDiagramZoomTests.swift @@ -0,0 +1,34 @@ +import CoreGraphics +import Testing + +@testable import TablePro + +@Suite("Query Plan Diagram Zoom") +struct QueryPlanDiagramZoomTests { + @Test("pinch scales from the gesture start") + func scalesFromGestureStart() { + let magnification = QueryPlanDiagramZoom.scaled(from: 1.5, by: 1.2) + #expect(abs(magnification - 1.8) < 0.0001) + } + + @Test("pinch clamps to the supported range") + func clampsPinchRange() { + #expect(QueryPlanDiagramZoom.scaled(from: 2.0, by: 2.0) == 3.0) + #expect(QueryPlanDiagramZoom.scaled(from: 0.5, by: 0.1) == 0.25) + } + + @Test("invalid pinch values preserve the current zoom") + func rejectsInvalidPinchValues() { + #expect(QueryPlanDiagramZoom.scaled(from: 1.5, by: .nan) == 1.5) + #expect(QueryPlanDiagramZoom.scaled(from: 1.5, by: .infinity) == 1.5) + #expect(QueryPlanDiagramZoom.scaled(from: 1.5, by: 0) == 1.5) + #expect(QueryPlanDiagramZoom.scaled(from: 1.5, by: -1) == 1.5) + } + + @Test("button zoom values use the same bounds") + func clampsButtonZoomRange() { + #expect(QueryPlanDiagramZoom.clamped(-10) == 0.25) + #expect(QueryPlanDiagramZoom.clamped(10) == 3.0) + #expect(QueryPlanDiagramZoom.clamped(.nan) == 1.0) + } +} diff --git a/docs/databases/mysql.mdx b/docs/databases/mysql.mdx index 44b79c2fc..384a8cdfc 100644 --- a/docs/databases/mysql.mdx +++ b/docs/databases/mysql.mdx @@ -58,7 +58,7 @@ Turn on **Cloud SQL Auth Proxy** in the connection form and enter the instance c ## Query Plans -**EXPLAIN** and **EXPLAIN FORMAT=JSON** results render as a visual plan diagram. See [EXPLAIN Visualization](/features/explain-visualization). +**EXPLAIN FORMAT=JSON**, **EXPLAIN FORMAT=TREE**, and **EXPLAIN ANALYZE** results render as a visual plan diagram or tree. Plain multi-column **EXPLAIN** output stays in the results grid. See [EXPLAIN Visualization](/features/explain-visualization). Visual EXPLAIN plan diagram diff --git a/docs/features/explain-visualization.mdx b/docs/features/explain-visualization.mdx index 8a7db3c9b..b69e76a2d 100644 --- a/docs/features/explain-visualization.mdx +++ b/docs/features/explain-visualization.mdx @@ -7,7 +7,14 @@ Click **Explain** in the query editor toolbar, or use **Query > Explain Query** Databases with multiple EXPLAIN variants show a dropdown: PostgreSQL offers **EXPLAIN** (estimated plan) and **EXPLAIN ANALYZE** (runs the query and shows actual timing); MySQL and MariaDB offer **EXPLAIN** and **EXPLAIN (JSON)**. Databases with a single variant, like SQLite or DuckDB, show a plain 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. +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. MySQL's TREE and ANALYZE text output is parsed into the diagram and tree views. Multi-column plans like MySQL's plain `EXPLAIN` table stay in the results grid. + + + MySQL EXPLAIN ANALYZE rendered as a plan diagram +