added typeaheead and introspetion
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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 }) ?? "" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user