added typeaheead and introspetion
This commit is contained in:
+240
-2
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user