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
+61 -2
View File
@@ -212,6 +212,57 @@ final class DatabaseModel: ObservableObject {
return selectedDatabase.isEmpty ? (selected?.database ?? "") : selectedDatabase
}
// Phase 62: compare successive successful schema introspections.
struct SchemaChange: Identifiable {
let id = UUID()
let category: String
let object: String
let detail: String
}
struct SchemaChangeReport {
let database: String
let checkedAt: Date
let changes: [SchemaChange]
}
@Published private(set) var schemaChangeReports: [String: SchemaChangeReport] = [:]
func schemaChangeReport(for database: String) -> SchemaChangeReport? {
guard let profile = selected else { return nil }
return schemaChangeReports[introspectionKey(database, profile.id)]
}
private func compareSchemas(previous: SchemaIntrospection, current: SchemaIntrospection) -> [SchemaChange] {
var changes: [SchemaChange] = []
func compareObjects(_ kind: String, _ old: [String], _ new: [String]) {
let oldSet = Set(old), newSet = Set(new)
for name in newSet.subtracting(oldSet).sorted() {
changes.append(SchemaChange(category: "Added \(kind)", object: name, detail: "New \(kind.lowercased())"))
}
for name in oldSet.subtracting(newSet).sorted() {
changes.append(SchemaChange(category: "Removed \(kind)", object: name, detail: "No longer exists"))
}
}
compareObjects("Table", previous.tables, current.tables)
compareObjects("View", previous.views, current.views)
let allObjects = Set(previous.columnsByTable.keys).union(current.columnsByTable.keys)
for object in allObjects.sorted() {
let oldColumns = Dictionary(uniqueKeysWithValues: (previous.columnsByTable[object] ?? []).map { ($0.name, $0) })
let newColumns = Dictionary(uniqueKeysWithValues: (current.columnsByTable[object] ?? []).map { ($0.name, $0) })
for name in Set(newColumns.keys).subtracting(oldColumns.keys).sorted() {
changes.append(SchemaChange(category: "Added Column", object: "\(object).\(name)", detail: newColumns[name]?.dataType ?? ""))
}
for name in Set(oldColumns.keys).subtracting(newColumns.keys).sorted() {
changes.append(SchemaChange(category: "Removed Column", object: "\(object).\(name)", detail: oldColumns[name]?.dataType ?? ""))
}
for name in Set(oldColumns.keys).intersection(newColumns.keys).sorted() {
guard let before = oldColumns[name], let after = newColumns[name] else { continue }
if before.dataType != after.dataType || before.nullable != after.nullable || before.key != after.key || before.defaultValue != after.defaultValue || before.extra != after.extra {
changes.append(SchemaChange(category: "Changed Column", object: "\(object).\(name)", detail: "\(before.dataType) → \(after.dataType)"))
}
}
}
return changes
}
@Published private(set) var introspections: [String: SchemaIntrospection] = [:]
private let introspectionStorageKey = "DBSyn.introspection.snapshots.v2"
private func persistIntrospections() {
@@ -268,11 +319,19 @@ final class DatabaseModel: ObservableObject {
}
// Ignore results from a connection that has been removed while loading.
guard profiles.contains(where: { $0.id == profile.id }) else { return }
introspections[key] = SchemaIntrospection(
let previous = introspections[key]
let current = SchemaIntrospection(
connectionID: profile.id, database: database,
loadedAt: Date(), tables: tables, views: views, columnsByTable: columns
)
if let snapshot = introspections[key] { applyIntrospection(snapshot) }
if let previous {
schemaChangeReports[key] = SchemaChangeReport(
database: database, checkedAt: current.loadedAt,
changes: compareSchemas(previous: previous, current: current)
)
}
introspections[key] = current
applyIntrospection(current)
persistIntrospections()
status = "Introspection refreshed: \(tables.count) tables, \(views.count) views, \(columns.values.reduce(0) { $0 + $1.count }) columns in \(database)"
} catch {
+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 }) ?? "" }
}
}
}
+9
View File
@@ -0,0 +1,9 @@
# DBSyn Phase 62 — Schema Change Review
Right-click a database in the Schemas navigator and choose **Review Schema Changes…**.
Select **Refresh Introspection** to retrieve the latest table, view, and column metadata. DBSyn compares the new snapshot with the previous cached snapshot, reporting added and removed tables/views/columns, and changed column attributes. Existing persistent introspection snapshots provide the baseline across launches. The change report is session-only; the current snapshot is saved for the next comparison.
The first introspection without a previous snapshot establishes the baseline and does not report differences. Failed introspections preserve the previous snapshot and do not create a comparison report. No database schema is modified by this feature.
Validation: source changes reviewed and ZIP integrity checked; macOS compilation and live MySQL testing remain necessary.
+14
View File
@@ -0,0 +1,14 @@
# Phase 63 — Query Plan Review
Extends the existing EXPLAIN inspector with a conservative, read-only query-plan review panel.
- Summarises MySQL tabular EXPLAIN steps and flags large full scans (estimated rows >= 10,000), extra sorting and temporary results.
- Explains each finding and provides context-aware next steps without creating indexes or running SELECT statements.
- Offers **Copy Review** to share a plain-text report with SQL and findings.
- Supports both System, Light and Dark appearances through adaptive SwiftUI colours.
- JSON EXPLAIN remains supported; switch to Table to use the per-step diagnostic rules.
- Existing SQL autosuggest, persistent introspection, schema-change review and graph dashboards are preserved.
The review uses optimizer estimates, not measured execution timings. An unflagged plan is not proof of good performance.
Validation: static source checks and ZIP integrity only. Requires macOS Xcode build and live MySQL verification.
+5
View File
@@ -0,0 +1,5 @@
# Phase 64 — Schema Structure Comparison
Right-click a schema and choose **Compare Schema Structures…**. Select a second database on the same connection. Refresh source and target introspection snapshots, review tables, views, columns and column attribute differences, and copy a text report. This phase is deliberately read-only: no DDL is executed and no automatic synchronisation is attempted.
Requires validation with Xcode and a live MySQL server.