Files
SQLWorkbench/DBSyn/DatabaseModel.swift
T

1158 lines
50 KiB
Swift

import Foundation
import AppKit
import Security
struct ConnectionProfile: Identifiable, Codable, Hashable {
var id = UUID()
var name = "Local MySQL"
var host = "127.0.0.1"
var port = 3306
var username = "root"
var database = ""
// Optional SSH transport. Existing saved profiles decode with these defaults.
var useSSHTunnel = false
var useTLS = true
var verifyTLS = true
var sshHost = ""
var sshPort = 22
var sshUser = ""
var sshIdentityFile = ""
enum CodingKeys: String, CodingKey { case id, name, host, port, username, database, useSSHTunnel, useTLS, verifyTLS, sshHost, sshPort, sshUser, sshIdentityFile }
init(id: UUID = UUID(), name: String = "Local MySQL", host: String = "127.0.0.1", port: Int = 3306, username: String = "root", database: String = "", useSSHTunnel: Bool = false, useTLS: Bool = true, verifyTLS: Bool = true, sshHost: String = "", sshPort: Int = 22, sshUser: String = "", sshIdentityFile: String = "") {
self.id = id; self.name = name; self.host = host; self.port = port; self.username = username; self.database = database
self.useSSHTunnel = useSSHTunnel; self.useTLS = useTLS; self.verifyTLS = verifyTLS; self.sshHost = sshHost; self.sshPort = sshPort; self.sshUser = sshUser; self.sshIdentityFile = sshIdentityFile
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
id = try c.decode(UUID.self, forKey: .id)
name = try c.decode(String.self, forKey: .name)
host = try c.decode(String.self, forKey: .host)
port = try c.decode(Int.self, forKey: .port)
username = try c.decode(String.self, forKey: .username)
database = try c.decode(String.self, forKey: .database)
useSSHTunnel = try c.decodeIfPresent(Bool.self, forKey: .useSSHTunnel) ?? false
useTLS = try c.decodeIfPresent(Bool.self, forKey: .useTLS) ?? true
verifyTLS = try c.decodeIfPresent(Bool.self, forKey: .verifyTLS) ?? true
sshHost = try c.decodeIfPresent(String.self, forKey: .sshHost) ?? ""
sshPort = try c.decodeIfPresent(Int.self, forKey: .sshPort) ?? 22
sshUser = try c.decodeIfPresent(String.self, forKey: .sshUser) ?? ""
sshIdentityFile = try c.decodeIfPresent(String.self, forKey: .sshIdentityFile) ?? ""
}
}
struct QueryResult {
var columns: [String] = []
var rows: [[String]] = []
var message = ""
}
enum PasswordStore {
private static let service = "DBSyn.MySQL"
private static let legacyService = "WorkbenchReplacement.MySQL"
private static func lookup(_ id: UUID, service: String) -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: id.uuidString,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var value: CFTypeRef?
guard SecItemCopyMatching(query as CFDictionary, &value) == errSecSuccess,
let data = value as? Data else { return nil }
return String(data: data, encoding: .utf8)
}
static func read(_ id: UUID) -> String {
if let password = lookup(id, service: service) { return password }
// One-time compatibility read. Never delete the legacy item.
if let password = lookup(id, service: legacyService) {
save(password, id: id)
return password
}
return ""
}
static func save(_ password: String, id: UUID) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: id.uuidString
]
SecItemDelete(query as CFDictionary)
guard !password.isEmpty else { return }
var attributes = query
attributes[kSecValueData as String] = Data(password.utf8)
SecItemAdd(attributes as CFDictionary, nil)
}
static func delete(_ id: UUID) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: id.uuidString
]
SecItemDelete(query as CFDictionary)
}
}
struct WorkbenchQueryTab: Identifiable {
let id: UUID
var title: String
var sql: String
var result: QueryResult
var fileURL: URL?
init(
id: UUID = UUID(),
title: String,
sql: String = "",
result: QueryResult = QueryResult(),
fileURL: URL? = nil
) {
self.id = id
self.title = title
self.sql = sql
self.result = result
self.fileURL = fileURL
}
}
struct SQLHistoryEntry: Identifiable, Codable {
var id = UUID()
var executedAt = Date()
var connectionName: String
var database: String
var sql: String
var succeeded: Bool
var detail: String
}
struct SavedSQLQuery: Identifiable, Codable {
var id = UUID()
var name: String
var sql: String
}
@MainActor
final class DatabaseModel: ObservableObject {
@Published var profiles: [ConnectionProfile] = []
@Published private(set) var queryHistory: [SQLHistoryEntry] = []
@Published private(set) var savedQueries: [SavedSQLQuery] = []
private let historyKey = "WorkbenchReplacement.queryHistory.v1"
private let savedQueriesKey = "WorkbenchReplacement.savedQueries.v1"
private let maxHistoryEntries = 200
private let recentScriptsKey = "WorkbenchReplacement.recentSQLScripts.v1"
@Published private(set) var recentSQLScripts: [String] = []
private func recordRecentSQLScript(_ url: URL) {
let path = url.standardizedFileURL.path
recentSQLScripts.removeAll { $0 == path }
recentSQLScripts.insert(path, at: 0)
recentSQLScripts = Array(recentSQLScripts.prefix(12))
UserDefaults.standard.set(recentSQLScripts, forKey: recentScriptsKey)
}
func clearRecentSQLScripts() {
recentSQLScripts = []
UserDefaults.standard.removeObject(forKey: recentScriptsKey)
}
func openRecentSQLScript(at path: String) {
let url = URL(fileURLWithPath: path)
do {
let contents = try String(contentsOf: url, encoding: .utf8)
saveActiveQuery()
let tab = WorkbenchQueryTab(title: url.lastPathComponent, sql: contents, fileURL: url)
queryTabs.append(tab)
activateQuery(tab.id)
recordRecentSQLScript(url)
status = "Opened \(url.lastPathComponent)"
NotificationCenter.default.post(name: .workbenchRevealSQLEditor, object: nil)
} catch {
status = "Could not open \(url.lastPathComponent): \(error.localizedDescription)"
}
}
func saveCurrentQuery(named name: String) {
let clean = name.trimmingCharacters(in: .whitespacesAndNewlines)
guard !clean.isEmpty, !sql.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
savedQueries.insert(SavedSQLQuery(name: clean, sql: sql), at: 0)
persistSavedQueries()
}
func openSavedQuery(_ entry: SavedSQLQuery) {
newQueryTab()
sql = entry.sql
if let id = activeQueryID, let index = queryTabs.firstIndex(where: { $0.id == id }) {
queryTabs[index].title = entry.name
queryTabs[index].sql = entry.sql
}
}
// Phase 68: updating a saved query keeps its stable identity and list position.
func updateSavedQuery(_ id: UUID, name: String, sql updatedSQL: String) {
let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedName.isEmpty,
!updatedSQL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
let index = savedQueries.firstIndex(where: { $0.id == id }) else { return }
savedQueries[index].name = trimmedName
savedQueries[index].sql = updatedSQL
persistSavedQueries()
}
func replaceSavedQueryWithEditor(_ id: UUID) {
guard let index = savedQueries.firstIndex(where: { $0.id == id }),
!sql.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
savedQueries[index].sql = sql
persistSavedQueries()
status = "Updated saved query: \(savedQueries[index].name)"
}
func deleteSavedQuery(_ id: UUID) {
savedQueries.removeAll { $0.id == id }
persistSavedQueries()
}
func clearQueryHistory() {
queryHistory.removeAll()
UserDefaults.standard.removeObject(forKey: historyKey)
}
private func persistSavedQueries() {
if let data = try? JSONEncoder().encode(savedQueries) {
UserDefaults.standard.set(data, forKey: savedQueriesKey)
}
}
private func recordQuery(_ statement: String, profile: ConnectionProfile, database: String, succeeded: Bool, detail: String) {
queryHistory.insert(SQLHistoryEntry(connectionName: profile.name, database: database, sql: statement, succeeded: succeeded, detail: detail), at: 0)
if queryHistory.count > maxHistoryEntries {
queryHistory.removeLast(queryHistory.count - maxHistoryEntries)
}
if let data = try? JSONEncoder().encode(queryHistory) {
UserDefaults.standard.set(data, forKey: historyKey)
}
}
// Phase 66: compare bounded table samples without modifying either database.
struct DataComparisonReport {
let sourceRows: Int
let targetRows: Int
let truncated: Bool
let text: String
}
func compareTableData(source: String, target: String, table: String) async throws -> DataComparisonReport {
guard let profile = selected else {
throw NSError(domain: "DBSyn.DataCompare", code: 1, userInfo: [NSLocalizedDescriptionKey: "Connect to MySQL first."])
}
guard let sourceMetadata = introspection(for: source),
let targetMetadata = introspection(for: target),
sourceMetadata.tables.contains(table), targetMetadata.tables.contains(table) else {
throw NSError(domain: "DBSyn.DataCompare", code: 2, userInfo: [NSLocalizedDescriptionKey: "Introspect both databases and select a shared base table."])
}
let sourceColumns = (sourceMetadata.columnsByTable[table] ?? []).map(\.name)
let targetColumns = (targetMetadata.columnsByTable[table] ?? []).map(\.name)
guard !sourceColumns.isEmpty, sourceColumns == targetColumns else {
throw NSError(domain: "DBSyn.DataCompare", code: 3, userInfo: [NSLocalizedDescriptionKey: "The table columns must match in name and order before comparing data."])
}
func quote(_ identifier: String) -> String {
"`" + identifier.replacingOccurrences(of: "`", with: "``") + "`"
}
let password = PasswordStore.read(profile.id)
let sql = "SELECT * FROM \(quote(table)) LIMIT 501"
let left = try await NativeMySQLRunner.run(profile: profile, database: source, password: password, sql: sql)
let right = try await NativeMySQLRunner.run(profile: profile, database: target, password: password, sql: sql)
guard left.columns == right.columns else {
throw NSError(domain: "DBSyn.DataCompare", code: 4, userInfo: [NSLocalizedDescriptionKey: "Returned column layouts differ; comparison aborted."])
}
let limit = 500
let truncated = left.rows.count > limit || right.rows.count > limit
let a = Array(left.rows.prefix(limit))
let b = Array(right.rows.prefix(limit))
// JSON encoding gives unambiguous row keys, including embedded delimiters.
func counts(_ rows: [[String]]) -> [String: Int] {
var output: [String: Int] = [:]
for row in rows {
guard let bytes = try? JSONEncoder().encode(row),
let key = String(data: bytes, encoding: .utf8) else { continue }
output[key, default: 0] += 1
}
return output
}
let leftCounts = counts(a)
let rightCounts = counts(b)
let keys = Set(leftCounts.keys).union(rightCounts.keys).sorted()
var lines = ["DBSyn Phase 66 — Read-only Table Data Comparison",
"Source: \(source).\(table) — \(a.count) sampled rows",
"Target: \(target).\(table) — \(b.count) sampled rows",
"Columns: \(left.columns.joined(separator: ", "))", ""]
if truncated {
lines.append("WARNING: Truncated to 500 rows per side; without ORDER BY, MySQL does not guarantee which rows are returned. These results are not a complete or deterministic comparison.")
lines.append("")
}
var differences = 0
for key in keys {
let leftCount = leftCounts[key, default: 0]
let rightCount = rightCounts[key, default: 0]
if leftCount != rightCount {
differences += 1
lines.append("Source count \(leftCount), target count \(rightCount): \(key)")
}
}
if differences == 0 { lines.append("No differences detected in retrieved rows.") }
else { lines.insert("Distinct differing row values: \(differences)", at: 4) }
return DataComparisonReport(sourceRows: a.count, targetRows: b.count, truncated: truncated, text: lines.joined(separator: "\n"))
}
// Phase 67: safe, explicit same-server INSERT preview for missing primary keys.
// No SQL is executed by this method; the user reviews the statement first.
struct DataSyncPreview {
let sql: String
let keyColumns: [String]
let explanation: String
}
func previewMissingRowsSync(source: String, target: String, table: String) throws -> DataSyncPreview {
guard selected != nil else {
throw NSError(domain: "DBSyn.DataSync", code: 1, userInfo: [NSLocalizedDescriptionKey: "Connect to MySQL first."])
}
guard source != target, !source.isEmpty, !target.isEmpty else {
throw NSError(domain: "DBSyn.DataSync", code: 2, userInfo: [NSLocalizedDescriptionKey: "Choose different source and target databases."])
}
guard let left = introspection(for: source), let right = introspection(for: target),
left.tables.contains(table), right.tables.contains(table) else {
throw NSError(domain: "DBSyn.DataSync", code: 3, userInfo: [NSLocalizedDescriptionKey: "Introspect both databases; the table must exist in both."])
}
let leftColumns = left.columnsByTable[table] ?? []
let rightColumns = right.columnsByTable[table] ?? []
guard !leftColumns.isEmpty,
leftColumns.map(\.name) == rightColumns.map(\.name),
zip(leftColumns, rightColumns).allSatisfy({ $0.dataType == $1.dataType && $0.nullable == $1.nullable }) else {
throw NSError(domain: "DBSyn.DataSync", code: 4, userInfo: [NSLocalizedDescriptionKey: "Column names, order, types and nullability must match before generating SQL."])
}
let keys = leftColumns.filter { $0.key == "PRI" }.map(\.name)
let rightKeys = rightColumns.filter { $0.key == "PRI" }.map(\.name)
guard !keys.isEmpty, keys == rightKeys else {
throw NSError(domain: "DBSyn.DataSync", code: 5, userInfo: [NSLocalizedDescriptionKey: "A matching primary key is required. No statement was generated."])
}
func q(_ value: String) -> String { "`" + value.replacingOccurrences(of: "`", with: "``") + "`" }
let sourceTable = "\(q(source)).\(q(table))"
let targetTable = "\(q(target)).\(q(table))"
let columns = leftColumns.map { q($0.name) }.joined(separator: ", ")
let sourceValues = leftColumns.map { "s.\(q($0.name))" }.joined(separator: ", ")
let predicates = keys.map { "t.\(q($0)) = s.\(q($0))" }.joined(separator: " AND ")
let sql = """
-- DBSyn Phase 67: REVIEW BEFORE EXECUTING.
-- Inserts only rows whose primary key is missing in the target.
-- Existing target rows are NEVER updated or deleted.
-- Take a backup and verify privileges, triggers and foreign keys first.
INSERT INTO \(targetTable) (\(columns))
SELECT \(sourceValues)
FROM \(sourceTable) AS s
WHERE NOT EXISTS (
SELECT 1 FROM \(targetTable) AS t
WHERE \(predicates)
);
"""
return DataSyncPreview(sql: sql, keyColumns: keys,
explanation: "Preview only. Uses matching primary key (\(keys.joined(separator: ", "))). Inserts missing keys; does not update changed rows. Review generated columns, triggers, constraints and backups before execution.")
}
@Published var selectedID: UUID?
// Invalidate in-flight UI updates when a connection is switched or edited.
private var connectionEpoch: UInt64 = 0
@Published var queryTabs: [WorkbenchQueryTab] = [
WorkbenchQueryTab(
title: "Query 1",
sql: "SELECT VERSION() AS server_version;"
)
]
@Published var activeQueryID: UUID?
@Published var sql = "SELECT VERSION() AS server_version;"
@Published var result = QueryResult()
@Published var databases: [String] = []
@Published var schemaTables: [String: [String]] = [:]
@Published var schemaViews: [String: [String]] = [:]
@Published var schemaProcedures: [String: [String]] = [:]
@Published var schemaFunctions: [String: [String]] = [:]
@Published var schemaColumns: [String: [String]] = [:]
// Connection-scoped metadata snapshots persist between launches.
struct IntrospectedColumn: Identifiable, Codable {
var id: String { "\(table).\(name)" }
let table: String
let name: String
let dataType: String
let nullable: Bool
let key: String
let defaultValue: String?
let extra: String
}
struct SchemaIntrospection: Codable {
let connectionID: UUID
let database: String
let loadedAt: Date
let tables: [String]
let views: [String]
let columnsByTable: [String: [IntrospectedColumn]]
}
// Resolve USE statements from the editor buffer even before Run is pressed.
// The completion provider must not depend on navigator selection alone.
var completionDatabase: String {
let pattern = #"(?im)^\s*USE\s+`?([A-Za-z_][A-Za-z0-9_]*)`?\s*;?"#
if let regex = try? NSRegularExpression(pattern: pattern) {
let value = sql as NSString
let range = NSRange(location: 0, length: value.length)
if let match = regex.matches(in: sql, range: range).last,
match.numberOfRanges > 1 {
return value.substring(with: match.range(at: 1))
}
}
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() {
if let data = try? JSONEncoder().encode(introspections) {
UserDefaults.standard.set(data, forKey: introspectionStorageKey)
}
}
private func applyIntrospection(_ snapshot: SchemaIntrospection) {
schemaTables[snapshot.database] = snapshot.tables
schemaViews[snapshot.database] = snapshot.views
schemaColumns[snapshot.database] = Array(Set(snapshot.columnsByTable.values.flatMap { $0 }.flatMap {
[$0.name, "\($0.table).\($0.name)"]
})).sorted()
}
func refreshIntrospection(database: String) { introspect(database: database) }
@Published private(set) var introspectionLoading: Set<String> = []
private func introspectionKey(_ database: String, _ connectionID: UUID) -> String {
"\(connectionID.uuidString):\(database)"
}
func introspection(for database: String) -> SchemaIntrospection? {
guard let profile = selected else { return nil }
return introspections[introspectionKey(database, profile.id)]
}
func introspect(database: String) {
guard let profile = selected else { status = "Select a connection"; return }
let key = introspectionKey(database, profile.id)
guard !introspectionLoading.contains(key) else { return }
introspectionLoading.insert(key)
status = "Introspecting \(database)…"
let password = PasswordStore.read(profile.id)
let escaped = database.replacingOccurrences(of: "'", with: "''")
Task {
defer { introspectionLoading.remove(key) }
do {
let tableResult = try await NativeMySQLRunner.run(
profile: profile, database: database, password: password,
sql: "SELECT TABLE_NAME, TABLE_TYPE FROM information_schema.TABLES WHERE TABLE_SCHEMA = '\(escaped)' ORDER BY TABLE_NAME"
)
let columnResult = try await NativeMySQLRunner.run(
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"
)
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]] = [:]
for row in columnResult.rows where row.count >= 7 {
let column = IntrospectedColumn(
table: row[0], name: row[1], dataType: row[2],
nullable: row[3] == "YES", key: row[4],
defaultValue: row[5] == "NULL" ? nil : row[5], extra: row[6]
)
columns[row[0], default: []].append(column)
}
// Ignore results from a connection that has been removed while loading.
guard profiles.contains(where: { $0.id == profile.id }),
selected?.id == profile.id else { return }
let previous = introspections[key]
let current = SchemaIntrospection(
connectionID: profile.id, database: database,
loadedAt: Date(), tables: tables, views: views, columnsByTable: columns
)
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 {
status = "Introspection failed for \(database): \(error.localizedDescription)"
}
}
}
@Published var selectedDatabase = ""
@Published var queryDurationText = ""
@Published var isRunning = false
@Published var status = "Not connected"
private let storageKey = "WorkbenchReplacement.connectionProfiles.native.v1"
init() {
recentSQLScripts = UserDefaults.standard.stringArray(forKey: recentScriptsKey) ?? []
if let data = UserDefaults.standard.data(forKey: historyKey),
let entries = try? JSONDecoder().decode([SQLHistoryEntry].self, from: data) {
queryHistory = Array(entries.prefix(maxHistoryEntries))
}
if let data = UserDefaults.standard.data(forKey: savedQueriesKey),
let entries = try? JSONDecoder().decode([SavedSQLQuery].self, from: data) {
savedQueries = entries
}
if let data = UserDefaults.standard.data(forKey: storageKey),
let decoded = try? JSONDecoder().decode([ConnectionProfile].self, from: data) { profiles = decoded }
if profiles.isEmpty { profiles = [ConnectionProfile()] }
if let data = UserDefaults.standard.data(forKey: introspectionStorageKey),
let cached = try? JSONDecoder().decode([String: SchemaIntrospection].self, from: data) {
introspections = cached.filter { entry in profiles.contains(where: { $0.id == entry.value.connectionID }) }
}
let rememberedID = UserDefaults.standard.string(forKey: "DBSyn.selectedConnectionID")
selectedID = profiles.first(where: { $0.id.uuidString == rememberedID })?.id ?? profiles.first?.id
if let id = selectedID {
for snapshot in introspections.values where snapshot.connectionID == id {
applyIntrospection(snapshot)
}
}
activeQueryID = queryTabs.first?.id
}
func newQueryTab() {
saveActiveQuery()
let tab = WorkbenchQueryTab(
title: "Query \(queryTabs.count + 1)"
)
queryTabs.append(tab)
activeQueryID = tab.id
sql = tab.sql
result = tab.result
}
func saveActiveQuery() {
guard let id = activeQueryID,
let index = queryTabs.firstIndex(where: { $0.id == id })
else { return }
queryTabs[index].sql = sql
queryTabs[index].result = result
}
func activateQuery(_ id: UUID) {
saveActiveQuery()
guard let tab = queryTabs.first(where: { $0.id == id })
else { return }
activeQueryID = tab.id
sql = tab.sql
result = tab.result
}
func closeQuery(_ id: UUID) {
saveActiveQuery()
queryTabs.removeAll { $0.id == id }
if queryTabs.isEmpty {
let tab = WorkbenchQueryTab(title: "Query 1")
queryTabs.append(tab)
}
if activeQueryID == id {
activeQueryID = nil
activateQuery(queryTabs[0].id)
}
}
func openSQLScript() {
let panel = NSOpenPanel()
panel.allowedContentTypes = [.plainText]
panel.allowsOtherFileTypes = true
panel.canChooseDirectories = false
guard panel.runModal() == .OK,
let url = panel.url,
let contents = try? String(contentsOf: url, encoding: .utf8)
else { return }
saveActiveQuery()
let tab = WorkbenchQueryTab(
title: url.lastPathComponent,
sql: contents,
fileURL: url
)
queryTabs.append(tab)
activateQuery(tab.id)
recordRecentSQLScript(url)
NotificationCenter.default.post(name: .workbenchRevealSQLEditor, object: nil)
status = "Opened \(url.lastPathComponent)"
}
func saveSQLScript(asNewFile: Bool = false) {
saveActiveQuery()
guard let id = activeQueryID,
let index = queryTabs.firstIndex(where: { $0.id == id })
else { return }
var destination = asNewFile ? nil : queryTabs[index].fileURL
if destination == nil {
let panel = NSSavePanel()
panel.nameFieldStringValue = "query.sql"
panel.canCreateDirectories = true
guard panel.runModal() == .OK else { return }
destination = panel.url
}
guard let url = destination else { return }
do {
try sql.write(to: url, atomically: true, encoding: .utf8)
queryTabs[index].fileURL = url
queryTabs[index].title = url.lastPathComponent
recordRecentSQLScript(url)
status = "Saved \(url.lastPathComponent)"
} catch {
status = "Save failed: \(error.localizedDescription)"
}
}
var selected: ConnectionProfile? { profiles.first { $0.id == selectedID } }
func selectConnection(_ id: UUID) {
guard let profile = profiles.first(where: { $0.id == id }) else { return }
guard selectedID != id else { return }
saveActiveQuery()
connectionEpoch &+= 1
if let oldID = selectedID { NativeMySQLRunner.disconnect(profileID: oldID) }
result = QueryResult()
isRunning = false
selectedID = id
UserDefaults.standard.set(id.uuidString, forKey: "DBSyn.selectedConnectionID")
selectedDatabase = profile.database
databases = []
status = "Selected connection: \(profile.name)"
loadDatabases()
}
func deleteConnection(_ id: UUID) {
guard profiles.count > 1 else { return }
NativeMySQLRunner.disconnect(profileID: id)
PasswordStore.delete(id)
profiles.removeAll { $0.id == id }
if selectedID == id, let first = profiles.first { selectConnection(first.id) }
persist()
}
func save(_ profile: ConnectionProfile, password: String) {
connectionEpoch &+= 1
if let i = profiles.firstIndex(where: { $0.id == profile.id }) { profiles[i] = profile }
else { profiles.append(profile) }
PasswordStore.save(password, id: profile.id)
NativeMySQLRunner.disconnect(profileID: profile.id)
selectedID = profile.id
selectedDatabase = profile.database
UserDefaults.standard.set(profile.id.uuidString, forKey: "DBSyn.selectedConnectionID")
persist()
loadDatabases()
}
private func persist() {
if let data = try? JSONEncoder().encode(profiles) { UserDefaults.standard.set(data, forKey: storageKey) }
}
func formatSQL() {
sql = SQLFormatter.uppercaseKeywords(sql)
}
func loadTables(database: String) {
guard let profile = selected else {
status = "Select a MySQL connection"
return
}
let password = PasswordStore.read(profile.id)
let escaped = database.replacingOccurrences(
of: "`",
with: "``"
)
status = "Loading tables from \(database)…"
let epoch = connectionEpoch
Task {
do {
let parsed = try await NativeMySQLRunner.run(
profile: profile,
database: database,
password: password,
sql: "SHOW FULL TABLES FROM `\(escaped)`"
)
guard selectedID == profile.id, connectionEpoch == epoch else { return }
var tables: [String] = []
var views: [String] = []
for row in parsed.rows {
guard let name = row.first else {
continue
}
if row.count > 1 && row[1] == "VIEW" {
views.append(name)
} else {
tables.append(name)
}
}
schemaTables[database] = tables
schemaViews[database] = views
// Refresh cached structure when the server reports a changed object list.
if let snapshot = introspection(for: database),
(Set(snapshot.tables) != Set(tables) || Set(snapshot.views) != Set(views)) {
introspect(database: database)
}
// Routine metadata is separate from SHOW FULL TABLES.
// Failure (e.g. insufficient INFORMATION_SCHEMA privileges)
// must not prevent browsing tables and views.
do {
let quotedSchema = database.replacingOccurrences(of: "'", with: "''")
let routineResult = try await NativeMySQLRunner.run(
profile: profile, database: database, password: password,
sql: "SELECT ROUTINE_NAME, ROUTINE_TYPE FROM information_schema.ROUTINES WHERE ROUTINE_SCHEMA = '\(quotedSchema)' ORDER BY ROUTINE_NAME"
)
guard selectedID == profile.id, connectionEpoch == epoch else { return }
schemaProcedures[database] = routineResult.rows.compactMap { row in
row.count >= 2 && row[1].uppercased() == "PROCEDURE" ? row[0] : nil
}
schemaFunctions[database] = routineResult.rows.compactMap { row in
row.count >= 2 && row[1].uppercased() == "FUNCTION" ? row[0] : nil
}
} catch {
guard selectedID == profile.id, connectionEpoch == epoch else { return }
schemaProcedures[database] = []
schemaFunctions[database] = []
}
// Cache column names for SQL completion. The lookup is read-only,
// scoped to the selected schema and uses the current connection.
// Failures (e.g. metadata permissions) do not prevent table browsing.
do {
let quotedSchema = database.replacingOccurrences(of: "'", with: "''")
let columnResult = try await NativeMySQLRunner.run(
profile: profile,
database: database,
password: password,
sql: "SELECT TABLE_NAME, COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = '\(quotedSchema)' ORDER BY TABLE_NAME, ORDINAL_POSITION"
)
guard selectedID == profile.id, connectionEpoch == epoch else { return }
var names = Set<String>()
for row in columnResult.rows where row.count >= 2 {
names.insert(row[1])
names.insert("\(row[0]).\(row[1])")
}
if introspection(for: database) == nil { schemaColumns[database] = names.sorted() }
} catch {
guard selectedID == profile.id, connectionEpoch == epoch else { return }
if introspection(for: database) == nil { schemaColumns[database] = [] }
}
status = "Loaded \(tables.count) tables and \(views.count) views from \(database)"
// Revalidate column definitions as well as object names on reconnect/refresh.
if introspection(for: database) != nil { introspect(database: database) }
} catch {
guard selectedID == profile.id, connectionEpoch == epoch else { return }
status = "Unable to load tables: \(error.localizedDescription)"
result = QueryResult(message: error.localizedDescription)
}
}
}
func selectTable(database: String, table: String) {
let db = database.replacingOccurrences(
of: "`",
with: "``"
)
let tbl = table.replacingOccurrences(
of: "`",
with: "``"
)
selectedDatabase = database
let configuredLimit = UserDefaults.standard.integer(
forKey: "preferences.defaultRowLimit"
)
let rowLimit = configuredLimit > 0
? min(configuredLimit, 100000)
: 500
sql = "SELECT * FROM `\(db)`.`\(tbl)` LIMIT \(rowLimit);"
runSQL()
}
func loadDatabases() { execute("SHOW DATABASES;", listingDatabases: true) }
func runSQL() {
if UserDefaults.standard.object(
forKey: "preferences.uppercaseKeywords"
) as? Bool ?? true {
formatSQL()
}
execute(sql, listingDatabases: false)
}
func runSQL(statement: String) {
guard !statement.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
execute(statement, listingDatabases: false)
}
private func execute(_ statement: String, listingDatabases: Bool) {
guard let profile = selected else { status = "Select a connection"; return }
let statements = listingDatabases ? [statement] : SQLStatementSplitter.split(statement)
guard !statements.isEmpty else { return }
isRunning = true
if !listingDatabases { queryDurationText = "Running…" }
let queryStarted = Date()
status = "Executing…"
let password = PasswordStore.read(profile.id)
let epoch = connectionEpoch
Task {
var activeDB = listingDatabases ? "" : (selectedDatabase.isEmpty ? profile.database : selectedDatabase)
do {
var lastResult = QueryResult()
for item in statements {
// Each query is sent separately: COM_QUERY accepts one SQL
// statement, not an entire semicolon-separated editor buffer.
let parsed = try await NativeMySQLRunner.run(
profile: profile, database: activeDB, password: password, sql: item
)
guard selectedID == profile.id, connectionEpoch == epoch else { return }
lastResult = parsed
if let usedSchema = SQLStatementSplitter.usedDatabase(item) {
activeDB = usedSchema
selectedDatabase = usedSchema
// Metadata is cached in memory for native SQL completion.
loadTables(database: usedSchema)
introspect(database: usedSchema)
}
}
guard selectedID == profile.id, connectionEpoch == epoch else { return }
if listingDatabases {
databases = lastResult.rows.compactMap { $0.first }
status = "Connected to \(profile.name)"
} else {
result = lastResult
if let id = activeQueryID,
let index = queryTabs.firstIndex(where: { $0.id == id }) {
queryTabs[index].sql = sql
queryTabs[index].result = lastResult
}
status = "Executed \(statements.count) statement(s) (\(lastResult.rows.count) rows)"
recordQuery(statement, profile: profile, database: activeDB, succeeded: true, detail: status)
}
} catch {
// An old connection must not overwrite the newly selected
// connection's state when its request finishes later.
guard selectedID == profile.id, connectionEpoch == epoch else { return }
status = "Error"
result = QueryResult(message: error.localizedDescription)
if !listingDatabases {
recordQuery(statement, profile: profile, database: activeDB, succeeded: false, detail: error.localizedDescription)
}
}
if !listingDatabases {
queryDurationText = String(format: "%.3f s", Date().timeIntervalSince(queryStarted))
}
if selectedID == profile.id && connectionEpoch == epoch { isRunning = false }
}
}
}
// Splits editor scripts without splitting semicolons inside quoted values or comments.
// Internal visibility allows the DBSynTests target to exercise the production parser via @testable import.
enum SQLStatementSplitter {
static func split(_ source: String) -> [String] {
let chars = Array(source)
var output: [String] = []
var current = ""
var state = 0 // 0 normal, 1 single quote, 2 double quote, 3 identifier, 4 line comment, 5 block comment
var i = 0
while i < chars.count {
let c = chars[i]
let next = i + 1 < chars.count ? chars[i + 1] : "\0"
if state == 4 {
if c == "\n" || c == "\r" { state = 0; current.append(" ") }
i += 1; continue
}
if state == 5 {
if c == "*" && next == "/" { state = 0; i += 2; current.append(" ") }
else { i += 1 }
continue
}
if state == 0 {
if c == "#" || (c == "-" && next == "-" && (i + 2 == chars.count || chars[i + 2].isWhitespace)) {
state = 4; i += c == "#" ? 1 : 2; continue
}
if c == "/" && next == "*" { state = 5; i += 2; continue }
if c == "'" { state = 1 }
else if c == "\"" { state = 2 }
else if c == "`" { state = 3 }
else if c == ";" {
let trimmed = current.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty { output.append(trimmed) }
current = ""; i += 1; continue
}
} else {
if c == "\\" && state != 3 && i + 1 < chars.count {
current.append(c); current.append(next); i += 2; continue
}
if (state == 1 && c == "'") || (state == 2 && c == "\"") || (state == 3 && c == "`") {
if next == c { current.append(c); current.append(next); i += 2; continue }
state = 0
}
}
current.append(c); i += 1
}
let trimmed = current.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty { output.append(trimmed) }
return output
}
static func usedDatabase(_ statement: String) -> String? {
let pattern = #"(?is)^\s*USE\s+(?:`((?:``|[^`])+)`|([A-Za-z0-9_$]+))\s*$"#
guard let regex = try? NSRegularExpression(pattern: pattern),
let match = regex.firstMatch(in: statement, range: NSRange(statement.startIndex..., in: statement)) else { return nil }
for index in [1, 2] where match.range(at: index).location != NSNotFound {
if let range = Range(match.range(at: index), in: statement) {
return String(statement[range]).replacingOccurrences(of: "``", with: "`")
}
}
return nil
}
}
// MARK: - SQL Keyword Formatter
enum SQLFormatter {
private static let keywords: Set<String> = [
"SELECT", "FROM", "WHERE", "INSERT", "INTO", "VALUES",
"UPDATE", "DELETE", "CREATE", "ALTER", "DROP", "TABLE",
"DATABASE", "SCHEMA", "USE", "SHOW", "DESCRIBE", "EXPLAIN",
"JOIN", "INNER", "LEFT", "RIGHT", "FULL", "OUTER",
"ON", "AS", "AND", "OR", "NOT", "NULL", "IS", "IN",
"BETWEEN", "LIKE", "ORDER", "BY", "GROUP", "HAVING",
"LIMIT", "OFFSET", "ASC", "DESC", "DISTINCT", "ALL",
"PRIMARY", "KEY", "FOREIGN", "REFERENCES", "INDEX",
"UNIQUE", "DEFAULT", "AUTO_INCREMENT", "IF", "EXISTS",
"CASCADE", "RESTRICT", "SET", "BEGIN", "COMMIT",
"ROLLBACK", "TRANSACTION", "UNION", "CASE", "WHEN",
"THEN", "ELSE", "END", "TRUE", "FALSE", "COUNT",
"SUM", "AVG", "MIN", "MAX", "HAVING", "WITH",
"RECURSIVE", "TRUNCATE", "REPLACE", "VIEW",
"PROCEDURE", "FUNCTION", "TRIGGER", "GRANT",
"REVOKE", "DISTINCT", "CURRENT_TIMESTAMP"
]
static func uppercaseKeywords(_ sql: String) -> String {
let chars = Array(sql)
var output = ""
var index = 0
enum State {
case normal
case singleQuote
case doubleQuote
case backtick
case lineComment
case blockComment
}
var state = State.normal
while index < chars.count {
let ch = chars[index]
let next: Character? = index + 1 < chars.count
? chars[index + 1] : nil
switch state {
case .normal:
if ch == "'" {
state = .singleQuote
output.append(ch)
index += 1
} else if ch == "\"" {
state = .doubleQuote
output.append(ch)
index += 1
} else if ch == "`" {
state = .backtick
output.append(ch)
index += 1
} else if ch == "-" && next == "-" &&
(index + 2 == chars.count ||
chars[index + 2].isWhitespace) {
state = .lineComment
output += "--"
index += 2
} else if ch == "#" {
state = .lineComment
output.append(ch)
index += 1
} else if ch == "/" && next == "*" {
state = .blockComment
output += "/*"
index += 2
} else if ch.isLetter || ch == "_" {
let start = index
while index < chars.count &&
(chars[index].isLetter ||
chars[index].isNumber ||
chars[index] == "_" ||
chars[index] == "$") {
index += 1
}
let word = String(chars[start..<index])
let upper = word.uppercased()
output += keywords.contains(upper) ? upper : word
} else {
output.append(ch)
index += 1
}
case .singleQuote:
output.append(ch)
index += 1
if ch == "\\" && index < chars.count {
output.append(chars[index])
index += 1
} else if ch == "'" {
if index < chars.count && chars[index] == "'" {
output.append(chars[index])
index += 1
} else {
state = .normal
}
}
case .doubleQuote:
output.append(ch)
index += 1
if ch == "\\" && index < chars.count {
output.append(chars[index])
index += 1
} else if ch == "\"" {
if index < chars.count && chars[index] == "\"" {
output.append(chars[index])
index += 1
} else {
state = .normal
}
}
case .backtick:
output.append(ch)
index += 1
if ch == "`" {
if index < chars.count && chars[index] == "`" {
output.append(chars[index])
index += 1
} else {
state = .normal
}
}
case .lineComment:
output.append(ch)
index += 1
if ch == "\n" {
state = .normal
}
case .blockComment:
output.append(ch)
index += 1
if ch == "*" && index < chars.count &&
chars[index] == "/" {
output.append("/")
index += 1
state = .normal
}
}
}
return output
}
}