added typeaheead and introspetion

This commit is contained in:
Mark Gregory
2026-10-19 09:24:12 +01:00
parent c102bef2a6
commit 4cbb5624af
8 changed files with 836 additions and 4 deletions
+240 -2
View File
@@ -25,6 +25,8 @@ struct MainView: View {
@State private var resultSortAscending = true
@State private var hiddenResultColumns: Set<Int> = []
@State private var resultPage = 0
@State private var selectedResultRow: [String]? = nil
@State private var editResultRow: [String]? = nil
private let resultPageSize = 1_000
@State private var showingEditor = false
@State private var showingDatabaseTransfer = false
@@ -35,6 +37,7 @@ struct MainView: View {
@State private var showingQueryPlan = false
@State private var schemaChangesDatabase: String? = nil
@State private var schemaCompareDatabase: String? = nil
@State private var schemaDiagramDatabase: String? = nil
@State private var dataCompareDatabase: String? = nil
@State private var showingQueryHistory = false
@State private var showingSavedQueries = false
@@ -115,6 +118,14 @@ struct MainView: View {
DBSynSchemaCompareView(model: model, sourceDatabase: database)
}
}
.sheet(isPresented: Binding(
get: { schemaDiagramDatabase != nil },
set: { if !$0 { schemaDiagramDatabase = nil } }
)) {
if let database = schemaDiagramDatabase {
DBSynSchemaDiagramView(model: model, database: database)
}
}
.sheet(isPresented: $showingQueryPlan) {
WorkbenchExplainView(model: model, statement: selectedSQL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? model.sql : selectedSQL)
}
@@ -507,6 +518,7 @@ 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("Generate Schema Diagram…") { schemaDiagramDatabase = db }
Button("Review Schema Changes…") { schemaChangesDatabase = db }
Button("Compare Schema Structures…") { schemaCompareDatabase = db }
Button("Compare Table Data…") { dataCompareDatabase = db }
@@ -1059,6 +1071,16 @@ struct MainView: View {
Spacer()
Button {
editResultRow = selectedResultRow
} label: {
Label("Edit Row…", systemImage: "pencil")
}
.disabled(selectedResultRow == nil || editableResultTarget == nil)
.help(editableResultTarget == nil
? "Editing requires a SELECT * from a single base table with a primary key and loaded introspection metadata."
: selectedResultRow == nil ? "Select a result row first." : "Edit selected row and preview SQL")
if !model.result.columns.isEmpty {
TextField("Filter rows", text: $resultFilter)
.textFieldStyle(.roundedBorder)
@@ -1126,6 +1148,16 @@ struct MainView: View {
.background(WB.header)
}
}
.sheet(isPresented: Binding(
get: { editResultRow != nil },
set: { if !$0 { editResultRow = nil } }
)) {
if let row = editResultRow, let target = editableResultTarget {
DBSynInlineRowEditor(model: model, database: target.database,
table: target.table, columns: model.result.columns, original: row)
}
}
.onChange(of: model.result.rows) { _, _ in selectedResultRow = nil }
.onChange(of: resultFilter) { _, _ in resultPage = 0 }
.onChange(of: resultSortColumn) { _, _ in resultPage = 0 }
.onChange(of: resultSortAscending) { _, _ in resultPage = 0 }
@@ -1139,7 +1171,30 @@ struct MainView: View {
let rows = displayedRows
let start = min(resultPage * resultPageSize, rows.count)
let end = min(start + resultPageSize, rows.count)
return NativeResultGrid(columns: model.result.columns, rows: Array(rows[start..<end]), hiddenColumnIndices: hiddenResultColumns)
return NativeResultGrid(columns: model.result.columns, rows: Array(rows[start..<end]), hiddenColumnIndices: hiddenResultColumns, onSelect: { selectedResultRow = $0 }, onEdit: { row in
selectedResultRow = row
if editableResultTarget != nil { editResultRow = row }
})
}
// Editing is restricted to a simple SELECT * from a single base table.
// Arbitrary joins, expressions and aliases cannot be mapped safely to rows.
private var editableResultTarget: (database: String, table: String)? {
let statements = model.sql.components(separatedBy: ";")
guard let select = statements.last(where: { $0.range(of: #"(?i)\bSELECT\b"#, options: .regularExpression) != nil }) else { return nil }
let pattern = #"(?is)^\s*SELECT\s+\*\s+FROM\s+((?:`[^`]+`|[A-Za-z_][A-Za-z0-9_]*)(?:\.(?:`[^`]+`|[A-Za-z_][A-Za-z0-9_]*))?)(?:\s+LIMIT\s+\d+(?:\s*,\s*\d+|\s+OFFSET\s+\d+)?)?\s*$"#
guard let regex = try? NSRegularExpression(pattern: pattern),
let match = regex.firstMatch(in: select, range: NSRange(select.startIndex..., in: select)),
let range = Range(match.range(at: 1), in: select) else { return nil }
let parts = String(select[range]).replacingOccurrences(of: "`", with: "").components(separatedBy: ".")
let database = parts.count == 2 ? parts[0] : model.completionDatabase
let table = parts.last ?? ""
guard !database.isEmpty, let snapshot = model.introspection(for: database),
snapshot.tables.contains(where: { $0.caseInsensitiveCompare(table) == .orderedSame }),
let fields = snapshot.columnsByTable[table],
fields.map(\.name).map({ $0.lowercased() }) == model.result.columns.map({ $0.lowercased() }),
fields.contains(where: { $0.key == "PRI" }) else { return nil }
return (database, table)
}
private var displayedRows: [[String]] {
@@ -1487,7 +1542,10 @@ struct SQLTextView: NSViewRepresentable {
func makeNSView(context: Context) -> NSScrollView {
let scroll = NSTextView.scrollableTextView()
let editor = scroll.documentView as! NSTextView
guard let editor = scroll.documentView as? NSTextView else {
assertionFailure("NSTextView.scrollableTextView() did not provide an NSTextView")
return scroll
}
let nativeAppearance = NSAppearance(named: colorScheme == .dark ? .darkAqua : .aqua)
scroll.appearance = nativeAppearance
@@ -2463,6 +2521,8 @@ struct NativeResultGrid: NSViewRepresentable {
let columns: [String]
let rows: [[String]]
var hiddenColumnIndices: Set<Int> = []
var onSelect: (([String]?) -> Void)? = nil
var onEdit: (([String]) -> Void)? = nil
@Environment(\.colorScheme) private var colorScheme
func makeCoordinator() -> Coordinator {
@@ -2500,6 +2560,15 @@ struct NativeResultGrid: NSViewRepresentable {
context.coordinator.columns = columns
context.coordinator.rows = rows
context.coordinator.onSelect = onSelect
context.coordinator.onEdit = onEdit
context.coordinator.tableView = table
let menu = NSMenu()
let editItem = NSMenuItem(title: "Edit Row…", action: #selector(Coordinator.editClickedRow(_:)), keyEquivalent: "")
editItem.target = context.coordinator
menu.addItem(editItem)
table.menu = menu
table.delegate = context.coordinator
table.dataSource = context.coordinator
@@ -2530,6 +2599,8 @@ struct NativeResultGrid: NSViewRepresentable {
context.coordinator.columns = columns
context.coordinator.rows = rows
context.coordinator.onSelect = onSelect
context.coordinator.onEdit = onEdit
if oldColumns != columns || Set(table.tableColumns.compactMap { column -> Int? in
let identifier = column.identifier.rawValue
@@ -2572,6 +2643,22 @@ struct NativeResultGrid: NSViewRepresentable {
var columns: [String] = []
var rows: [[String]] = []
var onSelect: (([String]?) -> Void)?
var onEdit: (([String]) -> Void)?
weak var tableView: NSTableView?
@objc func editClickedRow(_ sender: NSMenuItem) {
guard let table = tableView else { return }
let index = table.clickedRow >= 0 ? table.clickedRow : table.selectedRow
guard rows.indices.contains(index) else { return }
onEdit?(rows[index])
}
func tableViewSelectionDidChange(_ notification: Notification) {
guard let table = notification.object as? NSTableView else { return }
let index = table.selectedRow
onSelect?(index >= 0 && index < rows.count ? rows[index] : nil)
}
func numberOfRows(in tableView: NSTableView) -> Int {
rows.count
@@ -6864,3 +6951,154 @@ private struct DBSynDataCompareView: View {
}
}
}
// MARK: - Reviewed SQL row editing (Phase 72)
private struct DBSynInlineRowEditor: View {
@Environment(\.dismiss) private var dismiss
@ObservedObject var model: DatabaseModel
let database: String
let table: String
let columns: [String]
let original: [String]
@State private var values: [String]
@State private var executing = false
@State private var errorText: String?
init(model: DatabaseModel, database: String, table: String,
columns: [String], original: [String]) {
self.model = model
self.database = database
self.table = table
self.columns = columns
self.original = original
_values = State(initialValue: original)
}
private var metadata: [DatabaseModel.IntrospectedColumn] {
model.introspection(for: database)?.columnsByTable[table] ?? []
}
private var primaryIndices: [Int] {
columns.indices.filter { i in
metadata.contains { $0.name.caseInsensitiveCompare(columns[i]) == .orderedSame && $0.key == "PRI" }
}
}
private var changedIndices: [Int] {
columns.indices.filter { $0 < values.count && $0 < original.count && values[$0] != original[$0] }
}
private func identifier(_ value: String) -> String {
"`" + value.replacingOccurrences(of: "`", with: "``") + "`"
}
private func literal(_ value: String) -> String {
// MySQL backslash escapes are SQL-mode-dependent. Hexadecimal UTF-8
// literals avoid SQL injection and preserve quote/backslash characters.
"CONVERT(X'" + Data(value.utf8).map { String(format: "%02X", $0) }.joined() + "' USING utf8mb4)"
}
private var validationError: String? {
guard columns.count == original.count, values.count == columns.count,
metadata.count == columns.count else { return "Result columns do not match introspection metadata. Refresh introspection." }
guard !primaryIndices.isEmpty else { return "A primary key is required for safe row updates." }
guard !changedIndices.isEmpty else { return "Edit at least one non-key value to generate SQL." }
guard !changedIndices.contains(where: { primaryIndices.contains($0) }) else { return "Primary-key editing is not supported." }
// The native wire protocol currently renders SQL NULL as the text
// "NULL". Never generate SQL when this representation is ambiguous.
let checked = Set(primaryIndices + changedIndices)
for i in checked {
if metadata[i].nullable && original[i] == "NULL" {
return "Column \(columns[i]) may contain SQL NULL. Editing ambiguous NULL values is disabled."
}
}
return nil
}
private var generatedSQL: String {
guard validationError == nil else { return "" }
let assignments = changedIndices.map { i in
"\(identifier(columns[i])) = \(literal(values[i]))"
}.joined(separator: ",\n ")
// Optimistic checks prevent overwriting a value modified since the
// result grid was loaded. LIMIT 1 prevents accidental mass updates.
let checks = (primaryIndices + changedIndices).map { i in
"\(identifier(columns[i])) <=> \(literal(original[i]))"
}.joined(separator: " AND\n ")
return "UPDATE \(identifier(database)).\(identifier(table))\nSET \(assignments)\nWHERE \(checks)\nLIMIT 1;"
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Edit Row — \(database).\(table)").font(.title3.bold())
Text("Changes are staged locally. Review the SQL before applying them to MySQL.")
.foregroundStyle(.secondary)
ScrollView {
VStack(alignment: .leading, spacing: 8) {
ForEach(columns.indices, id: \.self) { i in
HStack {
Text(columns[i]).frame(width: 190, alignment: .leading)
TextField(columns[i], text: $values[i])
.textFieldStyle(.roundedBorder)
.disabled(primaryIndices.contains(i) || executing)
if primaryIndices.contains(i) {
Text("PRIMARY KEY").font(.caption).foregroundStyle(.secondary)
}
}
}
}
}
.frame(maxHeight: 290)
Text("SQL Preview").font(.headline)
ScrollView {
Text(validationError ?? generatedSQL)
.font(.system(.body, design: .monospaced))
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(10)
}
.frame(height: 140)
.background(Color(nsColor: .textBackgroundColor))
if let errorText {
Text(errorText).foregroundStyle(.red).textSelection(.enabled)
}
HStack {
Button("Copy SQL") {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(generatedSQL, forType: .string)
}
.disabled(validationError != nil)
Spacer()
Button("Cancel") { dismiss() }.disabled(executing)
Button(executing ? "Applying…" : "Apply SQL Changes") { apply() }
.buttonStyle(.borderedProminent)
.disabled(validationError != nil || executing)
}
}
.padding(20)
.frame(width: 780, height: 620)
}
private func apply() {
guard validationError == nil, let profile = model.selected else { return }
let statement = generatedSQL
let password = PasswordStore.read(profile.id)
executing = true
errorText = nil
Task {
do {
let response = try await NativeMySQLRunner.run(
profile: profile, database: database, password: password, sql: statement)
// A successful UPDATE may affect zero rows when a concurrent
// change invalidates the optimistic predicate; do not claim a save.
model.status = "UPDATE completed: \(response.message). Refresh results to verify the row."
dismiss()
model.runSQL()
} catch {
errorText = error.localizedDescription
}
executing = false
}
}
}