added typeaheead and introspetion

This commit is contained in:
Mark Gregory
2026-10-18 17:11:33 +01:00
parent bcd33b3fa5
commit 9609cb31c1
5 changed files with 373 additions and 2 deletions
+284
View File
@@ -33,6 +33,8 @@ struct MainView: View {
@State private var schemaEditorTarget: SchemaEditorTarget? = nil
@State private var objectEditorTarget: WorkbenchObjectEditorTarget? = nil
@State private var showingQueryPlan = false
@State private var schemaChangesDatabase: String? = nil
@State private var schemaCompareDatabase: String? = nil
@State private var showingQueryHistory = false
@State private var showingSavedQueries = false
@State private var showingSaveQuery = false
@@ -88,6 +90,22 @@ struct MainView: View {
model.loadDatabases()
}
}
.sheet(isPresented: Binding(
get: { schemaChangesDatabase != nil },
set: { if !$0 { schemaChangesDatabase = nil } }
)) {
if let database = schemaChangesDatabase {
DBSynSchemaChangesView(model: model, database: database)
}
}
.sheet(isPresented: Binding(
get: { schemaCompareDatabase != nil },
set: { if !$0 { schemaCompareDatabase = nil } }
)) {
if let database = schemaCompareDatabase {
DBSynSchemaCompareView(model: model, sourceDatabase: database)
}
}
.sheet(isPresented: $showingQueryPlan) {
WorkbenchExplainView(model: model, statement: selectedSQL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? model.sql : selectedSQL)
}
@@ -480,6 +498,8 @@ struct MainView: View {
if let snapshot = model.introspection(for: db) {
Text("Cached: \(snapshot.tables.count) tables, \(snapshot.views.count) views, \(snapshot.columnsByTable.values.reduce(0) { $0 + $1.count }) columns")
}
Button("Review Schema Changes…") { schemaChangesDatabase = db }
Button("Compare Schema Structures…") { schemaCompareDatabase = db }
Divider()
Button("Set as Default Schema") { model.selectedDatabase = db }
Button("Filter to This Schema") { schemaFilter = db }
@@ -1895,6 +1915,60 @@ private struct WorkbenchExplainView: View {
}
}
// Phase 63: A read-only, explainable query-plan health summary.
// This intentionally does not execute the SELECT or generate index DDL.
private struct PlanFinding: Identifiable {
let id: Int
let severity: String
let title: String
let guidance: String
}
private var planFindings: [PlanFinding] {
guard !useJSON, !plan.columns.isEmpty else { return [] }
let headings = plan.columns.map { $0.lowercased() }
func field(_ row: [String], _ name: String) -> String {
guard let index = headings.firstIndex(of: name), index < row.count else { return "" }
return row[index]
}
var findings: [PlanFinding] = []
for (step, row) in plan.rows.enumerated() {
let table = field(row, "table")
let target = table.isEmpty || table.uppercased() == "NULL" ? "Step \(step + 1)" : table
let access = field(row, "type").uppercased()
let extra = field(row, "extra").lowercased()
let estimatedRows = Int(field(row, "rows")) ?? 0
let key = field(row, "key")
if access == "ALL" && estimatedRows >= 10_000 {
findings.append(PlanFinding(id: findings.count, severity: "Review", title: "Large full scan: \(target)", guidance: "MySQL estimates \(estimatedRows.formatted()) rows. Inspect filters, JOIN conditions and available indexes; verify with representative data."))
} else if access == "ALL" {
findings.append(PlanFinding(id: findings.count, severity: "Info", title: "Full scan: \(target)", guidance: "A scan may be efficient for small tables. Check row counts and predicate selectivity before changing indexes."))
}
if extra.contains("using filesort") {
findings.append(PlanFinding(id: findings.count, severity: "Review", title: "Additional sorting: \(target)", guidance: "Review ORDER BY/GROUP BY columns and index ordering. Filesort does not necessarily imply disk I/O."))
}
if extra.contains("using temporary") {
findings.append(PlanFinding(id: findings.count, severity: "Review", title: "Temporary result: \(target)", guidance: "Inspect grouping, DISTINCT and derived tables. Temporary results are not inherently slow."))
}
if access == "ALL" && key.uppercased() != "NULL" && !key.isEmpty {
findings.append(PlanFinding(id: findings.count, severity: "Info", title: "Scan despite key: \(target)", guidance: "Inspect why the optimizer chose a scan rather than relying on the reported key value alone."))
}
}
return findings
}
private var planHealthLabel: String {
guard !plan.columns.isEmpty else { return "No plan" }
if useJSON { return "JSON plan — inspect manually" }
if planFindings.contains(where: { $0.severity == "Review" }) { return "Review suggested" }
return planFindings.isEmpty ? "No flagged patterns" : "Informational observations"
}
private var planSummaryText: String {
let lines = planFindings.map { "[\($0.severity)] \($0.title): \($0.guidance)" }
return (["DBSyn Query Plan Review", "Status: \(planHealthLabel)", "Optimizer steps: \(plan.rows.count)", "", "SQL:", normalizedStatement, "", "Findings:"] + (lines.isEmpty ? ["No flagged patterns in tabular EXPLAIN."] : lines) + ["", "These are estimates and heuristics, not measured execution times or index DDL."]).joined(separator: "\n")
}
private var diagnosticReport: String {
let sections = [
"SQL:", normalizedStatement,
@@ -2039,6 +2113,55 @@ private struct WorkbenchExplainView: View {
} else {
ContentUnavailableView("No plan loaded", systemImage: "chart.bar.doc.horizontal")
}
if !plan.columns.isEmpty {
VStack(alignment: .leading, spacing: 8) {
HStack {
Label("Query Plan Review", systemImage: "waveform.path.ecg.rectangle")
.font(.headline)
Text(planHealthLabel)
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
Spacer()
Text("\(plan.rows.count) optimizer steps")
.font(.caption)
.foregroundStyle(.secondary)
Button("Copy Review") {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(planSummaryText, forType: .string)
}
}
if useJSON {
Text("Switch to Table format for individual scan, sort and temporary-result findings.")
.font(.caption)
.foregroundStyle(.secondary)
} else if planFindings.isEmpty {
Text("No patterns flagged by the current conservative rules. This does not guarantee a fast query.")
.font(.callout)
.foregroundStyle(.secondary)
} else {
ScrollView {
VStack(alignment: .leading, spacing: 8) {
ForEach(planFindings) { finding in
HStack(alignment: .top, spacing: 10) {
Image(systemName: finding.severity == "Review" ? "exclamationmark.triangle" : "info.circle")
.foregroundStyle(finding.severity == "Review" ? Color.orange : Color.secondary)
VStack(alignment: .leading, spacing: 3) {
Text(finding.title).font(.callout.weight(.semibold))
Text(finding.guidance).font(.caption).foregroundStyle(.secondary)
}
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
.frame(maxHeight: 130)
}
}
.padding(10)
.background(Color(nsColor: .controlBackgroundColor))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
if showHistory {
DisclosureGroup("Saved plans for this query (\(matchingSnapshots.count))", isExpanded: $showHistory) {
if matchingSnapshots.isEmpty {
@@ -6150,3 +6273,164 @@ private enum WorkbenchCSVParser {
return rows
}
}
// MARK: - Phase 62: Schema change review
private struct DBSynSchemaChangesView: View {
@ObservedObject var model: DatabaseModel
let database: String
@Environment(\.dismiss) private var dismiss
var body: some View {
VStack(alignment: .leading, spacing: 14) {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text("Schema Changes — \(database)").font(.title2).bold()
Text("Compares the latest introspection with the previous cached snapshot.")
.foregroundStyle(.secondary)
}
Spacer()
Button("Refresh Introspection") { model.introspect(database: database) }
.disabled(model.selected == nil)
Button("Done") { dismiss() }
}
Divider()
if let report = model.schemaChangeReport(for: database) {
Text("Last checked: \(report.checkedAt.formatted(date: .abbreviated, time: .standard))")
.foregroundStyle(.secondary)
if report.changes.isEmpty {
ContentUnavailableView("No schema changes detected", systemImage: "checkmark.circle", description: Text("The latest snapshot matches the previous snapshot."))
} else {
List(report.changes) { change in
HStack(alignment: .top, spacing: 14) {
Text(change.category).font(.caption).foregroundStyle(.secondary)
.frame(width: 115, alignment: .leading)
VStack(alignment: .leading, spacing: 3) {
Text(change.object).font(.system(.body, design: .monospaced))
Text(change.detail).font(.caption).foregroundStyle(.secondary)
}
Spacer()
}
}
}
} else {
ContentUnavailableView("No comparison available", systemImage: "arrow.triangle.2.circlepath", description: Text("Refresh Introspection to compare against the cached snapshot. The first refresh creates a baseline."))
}
}
.padding(20)
.frame(minWidth: 680, minHeight: 420)
}
}
// MARK: - Phase 64: Read-only schema structure comparison
private struct DBSynSchemaCompareView: View {
@ObservedObject var model: DatabaseModel
let sourceDatabase: String
@State private var targetDatabase = ""
@Environment(\.dismiss) private var dismiss
private var source: DatabaseModel.SchemaIntrospection? { model.introspection(for: sourceDatabase) }
private var target: DatabaseModel.SchemaIntrospection? { model.introspection(for: targetDatabase) }
private struct Difference: Identifiable {
let id: String
let kind: String
let detail: String
}
private var differences: [Difference] {
guard let a = source, let b = target else { return [] }
var items: [Difference] = []
let left = Set(a.tables), right = Set(b.tables)
for name in left.subtracting(right).sorted() {
items.append(.init(id: "table-source-\(name)", kind: "Source only · Table", detail: name))
}
for name in right.subtracting(left).sorted() {
items.append(.init(id: "table-target-\(name)", kind: "Target only · Table", detail: name))
}
let lv = Set(a.views), rv = Set(b.views)
for name in lv.subtracting(rv).sorted() {
items.append(.init(id: "view-source-\(name)", kind: "Source only · View", detail: name))
}
for name in rv.subtracting(lv).sorted() {
items.append(.init(id: "view-target-\(name)", kind: "Target only · View", detail: name))
}
for table in Set(a.columnsByTable.keys).intersection(b.columnsByTable.keys).sorted() {
let lc = Dictionary(uniqueKeysWithValues: (a.columnsByTable[table] ?? []).map { ($0.name, $0) })
let rc = Dictionary(uniqueKeysWithValues: (b.columnsByTable[table] ?? []).map { ($0.name, $0) })
for name in Set(lc.keys).subtracting(rc.keys).sorted() {
items.append(.init(id: "column-source-\(table)-\(name)", kind: "Source only · Column", detail: "\(table).\(name)"))
}
for name in Set(rc.keys).subtracting(lc.keys).sorted() {
items.append(.init(id: "column-target-\(table)-\(name)", kind: "Target only · Column", detail: "\(table).\(name)"))
}
for name in Set(lc.keys).intersection(rc.keys).sorted() {
guard let x = lc[name], let y = rc[name] else { continue }
if x.dataType != y.dataType || x.nullable != y.nullable || x.key != y.key || x.defaultValue != y.defaultValue || x.extra != y.extra {
items.append(.init(id: "column-changed-\(table)-\(name)", kind: "Different · Column", detail: "\(table).\(name): \(x.dataType) → \(y.dataType)"))
}
}
}
return items
}
var body: some View {
VStack(alignment: .leading, spacing: 14) {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text("Compare Schema Structures").font(.title2).bold()
Text("Read-only comparison of cached introspection snapshots.").foregroundStyle(.secondary)
}
Spacer()
Button("Done") { dismiss() }
}
HStack {
Text("Source: \(sourceDatabase)")
Spacer()
Picker("Compare with", selection: $targetDatabase) {
Text("Choose database").tag("")
ForEach(model.databases.filter { $0 != sourceDatabase }, id: \.self) { name in
Text(name).tag(name)
}
}
.frame(width: 320)
}
HStack {
Button("Refresh Source") { model.introspect(database: sourceDatabase) }
Button("Refresh Target") { if !targetDatabase.isEmpty { model.introspect(database: targetDatabase) } }
.disabled(targetDatabase.isEmpty)
Spacer()
if source != nil && target != nil {
Button("Copy Differences") {
let output = differences.map { "\($0.kind): \($0.detail)" }.joined(separator: "\n")
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(output.isEmpty ? "No structural differences" : output, forType: .string)
}
}
}
Divider()
if targetDatabase.isEmpty {
ContentUnavailableView("Choose a target database", systemImage: "square.on.square", description: Text("Select another schema on this connection."))
} else if source == nil || target == nil {
ContentUnavailableView("Introspection required", systemImage: "memorychip", description: Text("Refresh source and target to load their tables, views and columns."))
} else if differences.isEmpty {
ContentUnavailableView("Structures match", systemImage: "checkmark.circle", description: Text("No differences found in cached table, view and column metadata."))
} else {
Text("\(differences.count) structural differences").foregroundStyle(.secondary)
List(differences) { item in
HStack(alignment: .top, spacing: 14) {
Text(item.kind).font(.caption).foregroundStyle(.secondary)
.frame(width: 155, alignment: .leading)
Text(item.detail).font(.system(.body, design: .monospaced))
Spacer()
}
}
}
}
.padding(20)
.frame(minWidth: 760, minHeight: 480)
.onAppear {
if targetDatabase.isEmpty { targetDatabase = model.databases.first(where: { $0 != sourceDatabase }) ?? "" }
}
}
}