Improved progress display
Previously, the progress bar updated only after completing an entire table. For large tables such as LIBARC, this could make the application appear to have stopped responding. Phase 10 updates the interface while rows are being exported.
This commit is contained in:
@@ -1432,6 +1432,86 @@ private struct WorkbenchExplainView: View {
|
||||
return message + " Estimates are not measured query timings."
|
||||
}
|
||||
|
||||
// Phase 24: Compare tabular plan steps by position and table, not only
|
||||
// by aggregate row estimates. EXPLAIN estimates are not runtime metrics.
|
||||
private var stepComparison: [String] {
|
||||
guard let baseline, !useJSON, baseline.format == "Table", !plan.columns.isEmpty else { return [] }
|
||||
func value(_ columns: [String], _ row: [String], _ field: String) -> String {
|
||||
guard let index = columns.firstIndex(where: { $0.caseInsensitiveCompare(field) == .orderedSame }),
|
||||
index < row.count else { return "—" }
|
||||
return row[index]
|
||||
}
|
||||
var details: [String] = []
|
||||
let count = max(baseline.resultRows.count, plan.rows.count)
|
||||
for index in 0..<count {
|
||||
guard index < baseline.resultRows.count else {
|
||||
details.append("Step \(index + 1): new optimizer step added.")
|
||||
continue
|
||||
}
|
||||
guard index < plan.rows.count else {
|
||||
details.append("Step \(index + 1): previous optimizer step removed.")
|
||||
continue
|
||||
}
|
||||
let before = baseline.resultRows[index]
|
||||
let after = plan.rows[index]
|
||||
let oldTable = value(baseline.columnNames, before, "table")
|
||||
let newTable = value(plan.columns, after, "table")
|
||||
let label = "Step \(index + 1) (\(oldTable) → \(newTable))"
|
||||
for field in ["type", "key", "possible_keys", "rows", "filtered", "Extra"] {
|
||||
let oldValue = value(baseline.columnNames, before, field)
|
||||
let newValue = value(plan.columns, after, field)
|
||||
if oldValue != newValue {
|
||||
details.append("\(label): \(field) changed from \(oldValue) to \(newValue).")
|
||||
}
|
||||
}
|
||||
}
|
||||
return details
|
||||
}
|
||||
|
||||
// Conservative index diagnostics: surface the optimizer's own candidates,
|
||||
// without suggesting unverified CREATE INDEX statements.
|
||||
private var indexDiagnostics: [String] {
|
||||
guard !useJSON, !plan.columns.isEmpty else { return [] }
|
||||
let names = plan.columns.map { $0.lowercased() }
|
||||
func value(_ row: [String], _ name: String) -> String {
|
||||
guard let index = names.firstIndex(of: name), index < row.count else { return "" }
|
||||
return row[index]
|
||||
}
|
||||
return plan.rows.compactMap { row -> String? in
|
||||
let table = value(row, "table")
|
||||
let access = value(row, "type").uppercased()
|
||||
let selected = value(row, "key")
|
||||
let possible = value(row, "possible_keys")
|
||||
let label = table.isEmpty ? "Query step" : "Table \(table)"
|
||||
if access == "ALL" && possible.uppercased() != "NULL" && !possible.isEmpty {
|
||||
return "\(label): full scan despite candidate indexes [\(possible)]. Review predicate selectivity and statistics."
|
||||
}
|
||||
if access == "ALL" && (possible.isEmpty || possible.uppercased() == "NULL") {
|
||||
return "\(label): full scan with no candidate index listed. Inspect WHERE/JOIN predicates before considering an index."
|
||||
}
|
||||
if selected.isEmpty || selected.uppercased() == "NULL" {
|
||||
return "\(label): optimizer did not select an index. This may be optimal for small tables."
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private var diagnosticReport: String {
|
||||
let sections = [
|
||||
"SQL:", normalizedStatement,
|
||||
"", "Plan observations:",
|
||||
planInsights.isEmpty ? "None" : planInsights.joined(separator: "\n"),
|
||||
"", "Index analysis:",
|
||||
indexDiagnostics.isEmpty ? "None (or JSON format selected)" : indexDiagnostics.joined(separator: "\n"),
|
||||
"", "Baseline comparison:",
|
||||
comparisonText ?? "No baseline selected",
|
||||
"", "Changed plan steps:",
|
||||
stepComparison.isEmpty ? "None" : stepComparison.joined(separator: "\n"),
|
||||
"", "These are optimizer estimates, not measured query performance."
|
||||
]
|
||||
return sections.joined(separator: "\n")
|
||||
}
|
||||
|
||||
private var normalizedStatement: String {
|
||||
var value = statement.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if value.hasSuffix(";") { value.removeLast() }
|
||||
@@ -1592,6 +1672,47 @@ private struct WorkbenchExplainView: View {
|
||||
.font(.callout)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
if !plan.columns.isEmpty {
|
||||
HStack {
|
||||
Label("Index analysis", systemImage: "magnifyingglass")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
Button("Copy Diagnostics") {
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(diagnosticReport, forType: .string)
|
||||
}
|
||||
}
|
||||
if !indexDiagnostics.isEmpty {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
ForEach(Array(indexDiagnostics.enumerated()), id: \.offset) { _, note in
|
||||
Text(note)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxHeight: 100)
|
||||
} else {
|
||||
Text("No additional index warnings for this plan format.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
if !stepComparison.isEmpty {
|
||||
DisclosureGroup("Changed plan steps (\(stepComparison.count))") {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
ForEach(Array(stepComparison.enumerated()), id: \.offset) { _, change in
|
||||
Text(change)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxHeight: 130)
|
||||
}
|
||||
}
|
||||
if !planInsights.isEmpty {
|
||||
DisclosureGroup("Plan observations (\(planInsights.count))") {
|
||||
ScrollView {
|
||||
|
||||
Reference in New Issue
Block a user