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:
Mark Gregory
2026-10-16 22:51:54 +01:00
parent 4e24e2daa4
commit 939522d98c
21 changed files with 293 additions and 169 deletions
+121
View File
@@ -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 {