added typeaheead and introspetion
This commit is contained in:
@@ -194,6 +194,7 @@ final class DatabaseModel: ObservableObject {
|
|||||||
let database: String
|
let database: String
|
||||||
let loadedAt: Date
|
let loadedAt: Date
|
||||||
let tables: [String]
|
let tables: [String]
|
||||||
|
let views: [String]
|
||||||
let columnsByTable: [String: [IntrospectedColumn]]
|
let columnsByTable: [String: [IntrospectedColumn]]
|
||||||
}
|
}
|
||||||
@Published private(set) var introspections: [String: SchemaIntrospection] = [:]
|
@Published private(set) var introspections: [String: SchemaIntrospection] = [:]
|
||||||
@@ -218,13 +219,14 @@ final class DatabaseModel: ObservableObject {
|
|||||||
do {
|
do {
|
||||||
let tableResult = try await NativeMySQLRunner.run(
|
let tableResult = try await NativeMySQLRunner.run(
|
||||||
profile: profile, database: database, password: password,
|
profile: profile, database: database, password: password,
|
||||||
sql: "SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = '\(escaped)' AND TABLE_TYPE = 'BASE TABLE' ORDER BY TABLE_NAME"
|
sql: "SELECT TABLE_NAME, TABLE_TYPE FROM information_schema.TABLES WHERE TABLE_SCHEMA = '\(escaped)' ORDER BY TABLE_NAME"
|
||||||
)
|
)
|
||||||
let columnResult = try await NativeMySQLRunner.run(
|
let columnResult = try await NativeMySQLRunner.run(
|
||||||
profile: profile, database: database, password: password,
|
profile: profile, database: database, password: password,
|
||||||
sql: "SELECT TABLE_NAME, COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLUMN_KEY, COLUMN_DEFAULT, EXTRA FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = '\(escaped)' ORDER BY TABLE_NAME, ORDINAL_POSITION"
|
sql: "SELECT TABLE_NAME, COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLUMN_KEY, COLUMN_DEFAULT, EXTRA FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = '\(escaped)' ORDER BY TABLE_NAME, ORDINAL_POSITION"
|
||||||
)
|
)
|
||||||
let tables = tableResult.rows.compactMap { $0.first }
|
let tables = tableResult.rows.compactMap { $0.count >= 2 && $0[1] == "BASE TABLE" ? $0[0] : nil }
|
||||||
|
let views = tableResult.rows.compactMap { $0.count >= 2 && $0[1] == "VIEW" ? $0[0] : nil }
|
||||||
var columns: [String: [IntrospectedColumn]] = [:]
|
var columns: [String: [IntrospectedColumn]] = [:]
|
||||||
for row in columnResult.rows where row.count >= 7 {
|
for row in columnResult.rows where row.count >= 7 {
|
||||||
let column = IntrospectedColumn(
|
let column = IntrospectedColumn(
|
||||||
@@ -238,13 +240,14 @@ final class DatabaseModel: ObservableObject {
|
|||||||
guard profiles.contains(where: { $0.id == profile.id }) else { return }
|
guard profiles.contains(where: { $0.id == profile.id }) else { return }
|
||||||
introspections[key] = SchemaIntrospection(
|
introspections[key] = SchemaIntrospection(
|
||||||
connectionID: profile.id, database: database,
|
connectionID: profile.id, database: database,
|
||||||
loadedAt: Date(), tables: tables, columnsByTable: columns
|
loadedAt: Date(), tables: tables, views: views, columnsByTable: columns
|
||||||
)
|
)
|
||||||
schemaTables[database] = tables
|
schemaTables[database] = tables
|
||||||
|
schemaViews[database] = views
|
||||||
schemaColumns[database] = Array(Set(columns.values.flatMap { $0 }.flatMap {
|
schemaColumns[database] = Array(Set(columns.values.flatMap { $0 }.flatMap {
|
||||||
[$0.name, "\($0.table).\($0.name)"]
|
[$0.name, "\($0.table).\($0.name)"]
|
||||||
})).sorted()
|
})).sorted()
|
||||||
status = "Introspection complete: \(tables.count) tables, \(columns.values.reduce(0) { $0 + $1.count }) columns in \(database) (RAM)"
|
status = "Introspection complete: \(tables.count) tables, \(views.count) views, \(columns.values.reduce(0) { $0 + $1.count }) columns in \(database) (RAM)"
|
||||||
} catch {
|
} catch {
|
||||||
status = "Introspection failed for \(database): \(error.localizedDescription)"
|
status = "Introspection failed for \(database): \(error.localizedDescription)"
|
||||||
}
|
}
|
||||||
@@ -431,8 +434,13 @@ final class DatabaseModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
schemaTables[database] = tables
|
if let snapshot = introspection(for: database) {
|
||||||
schemaViews[database] = views
|
schemaTables[database] = snapshot.tables
|
||||||
|
schemaViews[database] = snapshot.views
|
||||||
|
} else {
|
||||||
|
schemaTables[database] = tables
|
||||||
|
schemaViews[database] = views
|
||||||
|
}
|
||||||
|
|
||||||
// Routine metadata is separate from SHOW FULL TABLES.
|
// Routine metadata is separate from SHOW FULL TABLES.
|
||||||
// Failure (e.g. insufficient INFORMATION_SCHEMA privileges)
|
// Failure (e.g. insufficient INFORMATION_SCHEMA privileges)
|
||||||
@@ -470,9 +478,9 @@ final class DatabaseModel: ObservableObject {
|
|||||||
names.insert(row[1])
|
names.insert(row[1])
|
||||||
names.insert("\(row[0]).\(row[1])")
|
names.insert("\(row[0]).\(row[1])")
|
||||||
}
|
}
|
||||||
schemaColumns[database] = names.sorted()
|
if introspection(for: database) == nil { schemaColumns[database] = names.sorted() }
|
||||||
} catch {
|
} catch {
|
||||||
schemaColumns[database] = []
|
if introspection(for: database) == nil { schemaColumns[database] = [] }
|
||||||
}
|
}
|
||||||
|
|
||||||
status = "Loaded \(tables.count) tables and \(views.count) views from \(database)"
|
status = "Loaded \(tables.count) tables and \(views.count) views from \(database)"
|
||||||
|
|||||||
+46
-1
@@ -478,7 +478,7 @@ struct MainView: View {
|
|||||||
Label("Introspection — Load Table Structures into RAM", systemImage: "memorychip")
|
Label("Introspection — Load Table Structures into RAM", systemImage: "memorychip")
|
||||||
}
|
}
|
||||||
if let snapshot = model.introspection(for: db) {
|
if let snapshot = model.introspection(for: db) {
|
||||||
Text("Cached: \(snapshot.tables.count) tables, \(snapshot.columnsByTable.values.reduce(0) { $0 + $1.count }) columns")
|
Text("Cached: \(snapshot.tables.count) tables, \(snapshot.views.count) views, \(snapshot.columnsByTable.values.reduce(0) { $0 + $1.count }) columns")
|
||||||
}
|
}
|
||||||
Divider()
|
Divider()
|
||||||
Button("Set as Default Schema") { model.selectedDatabase = db }
|
Button("Set as Default Schema") { model.selectedDatabase = db }
|
||||||
@@ -1407,6 +1407,7 @@ struct SQLTextView: NSViewRepresentable {
|
|||||||
height: 8
|
height: 8
|
||||||
)
|
)
|
||||||
editor.delegate = context.coordinator
|
editor.delegate = context.coordinator
|
||||||
|
editor.isAutomaticTextCompletionEnabled = true
|
||||||
editor.string = text
|
editor.string = text
|
||||||
|
|
||||||
context.coordinator.highlight(editor)
|
context.coordinator.highlight(editor)
|
||||||
@@ -1462,6 +1463,8 @@ struct SQLTextView: NSViewRepresentable {
|
|||||||
final class Coordinator: NSObject, NSTextViewDelegate {
|
final class Coordinator: NSObject, NSTextViewDelegate {
|
||||||
var parent: SQLTextView
|
var parent: SQLTextView
|
||||||
var lastFocusRequest: Int
|
var lastFocusRequest: Int
|
||||||
|
private var pendingCompletion: DispatchWorkItem?
|
||||||
|
private var showingCompletion = false
|
||||||
|
|
||||||
init(_ parent: SQLTextView) {
|
init(_ parent: SQLTextView) {
|
||||||
self.parent = parent
|
self.parent = parent
|
||||||
@@ -1490,6 +1493,8 @@ struct SQLTextView: NSViewRepresentable {
|
|||||||
return Array(Set(completionWords + parent.completions))
|
return Array(Set(completionWords + parent.completions))
|
||||||
.filter { $0.range(of: prefix, options: [.anchored, .caseInsensitive]) != nil }
|
.filter { $0.range(of: prefix, options: [.anchored, .caseInsensitive]) != nil }
|
||||||
.sorted()
|
.sorted()
|
||||||
|
.prefix(60)
|
||||||
|
.map { $0 }
|
||||||
}
|
}
|
||||||
|
|
||||||
func textViewDidChangeSelection(_ notification: Notification) {
|
func textViewDidChangeSelection(_ notification: Notification) {
|
||||||
@@ -1508,6 +1513,46 @@ struct SQLTextView: NSViewRepresentable {
|
|||||||
|
|
||||||
parent.text = editor.string
|
parent.text = editor.string
|
||||||
highlight(editor)
|
highlight(editor)
|
||||||
|
// Never open AppKit's completion panel synchronously inside
|
||||||
|
// textDidChange: it re-enters NSTextView editing/delegate callbacks.
|
||||||
|
pendingCompletion?.cancel()
|
||||||
|
guard !showingCompletion,
|
||||||
|
let event = NSApp.currentEvent,
|
||||||
|
event.type == .keyDown,
|
||||||
|
let characters = event.characters,
|
||||||
|
characters.rangeOfCharacter(from: CharacterSet.letters.union(.decimalDigits).union(CharacterSet(charactersIn: "_"))) != nil,
|
||||||
|
!parent.completions.isEmpty else { return }
|
||||||
|
|
||||||
|
let selected = editor.selectedRange()
|
||||||
|
guard selected.length == 0, selected.location != NSNotFound else { return }
|
||||||
|
let content = editor.string as NSString
|
||||||
|
let cursor = min(selected.location, content.length)
|
||||||
|
var start = cursor
|
||||||
|
let valid = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "_"))
|
||||||
|
while start > 0 {
|
||||||
|
let scalar = content.substring(with: NSRange(location: start - 1, length: 1))
|
||||||
|
guard scalar.unicodeScalars.allSatisfy({ valid.contains($0) }) else { break }
|
||||||
|
start -= 1
|
||||||
|
}
|
||||||
|
guard cursor - start >= 2 else { return }
|
||||||
|
let expectedText = editor.string
|
||||||
|
let expectedCursor = cursor
|
||||||
|
let task = DispatchWorkItem { [weak self, weak editor] in
|
||||||
|
guard let self, let editor,
|
||||||
|
editor.window?.firstResponder === editor,
|
||||||
|
editor.string == expectedText,
|
||||||
|
editor.selectedRange().location == expectedCursor,
|
||||||
|
editor.selectedRange().length == 0,
|
||||||
|
!self.showingCompletion else { return }
|
||||||
|
self.showingCompletion = true
|
||||||
|
editor.complete(nil)
|
||||||
|
// AppKit may synchronously notify text changes while opening.
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
self?.showingCompletion = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pendingCompletion = task
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3, execute: task)
|
||||||
}
|
}
|
||||||
|
|
||||||
func highlight(_ editor: NSTextView) {
|
func highlight(_ editor: NSTextView) {
|
||||||
|
|||||||
Reference in New Issue
Block a user