From 9609cb31c1b741efb4a2ebe2e0d32b9c03de03ff Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Sun, 18 Oct 2026 17:11:33 +0100 Subject: [PATCH] added typeaheead and introspetion --- DBSyn/DatabaseModel.swift | 63 +++++- DBSyn/MainView.swift | 284 +++++++++++++++++++++++++++ docs/PHASE62-SCHEMA-CHANGE-REVIEW.md | 9 + docs/PHASE63-QUERY-PLAN-REVIEW.md | 14 ++ docs/PHASE64-SCHEMA-COMPARISON.md | 5 + 5 files changed, 373 insertions(+), 2 deletions(-) create mode 100644 docs/PHASE62-SCHEMA-CHANGE-REVIEW.md create mode 100644 docs/PHASE63-QUERY-PLAN-REVIEW.md create mode 100644 docs/PHASE64-SCHEMA-COMPARISON.md diff --git a/DBSyn/DatabaseModel.swift b/DBSyn/DatabaseModel.swift index 6cca89f..d3ef1b2 100644 --- a/DBSyn/DatabaseModel.swift +++ b/DBSyn/DatabaseModel.swift @@ -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 { diff --git a/DBSyn/MainView.swift b/DBSyn/MainView.swift index 243739e..4ac2af7 100644 --- a/DBSyn/MainView.swift +++ b/DBSyn/MainView.swift @@ -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 }) ?? "" } + } + } +} diff --git a/docs/PHASE62-SCHEMA-CHANGE-REVIEW.md b/docs/PHASE62-SCHEMA-CHANGE-REVIEW.md new file mode 100644 index 0000000..d232034 --- /dev/null +++ b/docs/PHASE62-SCHEMA-CHANGE-REVIEW.md @@ -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. diff --git a/docs/PHASE63-QUERY-PLAN-REVIEW.md b/docs/PHASE63-QUERY-PLAN-REVIEW.md new file mode 100644 index 0000000..7a539cd --- /dev/null +++ b/docs/PHASE63-QUERY-PLAN-REVIEW.md @@ -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. diff --git a/docs/PHASE64-SCHEMA-COMPARISON.md b/docs/PHASE64-SCHEMA-COMPARISON.md new file mode 100644 index 0000000..c2f6fa0 --- /dev/null +++ b/docs/PHASE64-SCHEMA-COMPARISON.md @@ -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.