1052 lines
44 KiB
Swift
1052 lines
44 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 = ""
|
|
}
|
|
struct QueryResult {
|
|
var columns: [String] = []
|
|
var rows: [[String]] = []
|
|
var message = ""
|
|
}
|
|
|
|
enum PasswordStore {
|
|
static func read(_ id: UUID) -> String {
|
|
let query: [String: Any] = [kSecClass as String: kSecClassGenericPassword,
|
|
kSecAttrService as String: "WorkbenchReplacement.MySQL",
|
|
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 "" }
|
|
return String(data: data, encoding: .utf8) ?? ""
|
|
}
|
|
static func save(_ password: String, id: UUID) {
|
|
let query: [String: Any] = [kSecClass as String: kSecClassGenericPassword,
|
|
kSecAttrService as String: "WorkbenchReplacement.MySQL",
|
|
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)
|
|
}
|
|
}
|
|
|
|
|
|
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?
|
|
@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 }) 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 }) }
|
|
}
|
|
selectedID = 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 save(_ profile: ConnectionProfile, password: String) {
|
|
if let i = profiles.firstIndex(where: { $0.id == profile.id }) { profiles[i] = profile }
|
|
else { profiles.append(profile) }
|
|
PasswordStore.save(password, id: profile.id)
|
|
selectedID = profile.id
|
|
persist()
|
|
}
|
|
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)…"
|
|
|
|
Task {
|
|
do {
|
|
let parsed = try await NativeMySQLRunner.run(
|
|
profile: profile,
|
|
database: database,
|
|
password: password,
|
|
sql: "SHOW FULL TABLES FROM `\(escaped)`"
|
|
)
|
|
|
|
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"
|
|
)
|
|
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 {
|
|
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"
|
|
)
|
|
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 {
|
|
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 {
|
|
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)
|
|
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
|
|
)
|
|
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)
|
|
}
|
|
}
|
|
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 {
|
|
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))
|
|
}
|
|
isRunning = false
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
|
|
// Splits editor scripts without splitting semicolons inside quoted values or comments.
|
|
private 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
|
|
}
|
|
}
|