Initial commit
This commit is contained in:
@@ -0,0 +1,294 @@
|
||||
import Foundation
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class DatabaseModel: ObservableObject {
|
||||
@Published var profiles: [ConnectionProfile] = []
|
||||
@Published var selectedID: UUID?
|
||||
@Published var sql = "SELECT VERSION() AS server_version;"
|
||||
@Published var result = QueryResult()
|
||||
@Published var databases: [String] = []
|
||||
@Published var schemaTables: [String: [String]] = [:]
|
||||
@Published var selectedDatabase = ""
|
||||
@Published var isRunning = false
|
||||
@Published var status = "Not connected"
|
||||
private let storageKey = "WorkbenchReplacement.connectionProfiles.native.v1"
|
||||
|
||||
init() {
|
||||
if let data = UserDefaults.standard.data(forKey: storageKey),
|
||||
let decoded = try? JSONDecoder().decode([ConnectionProfile].self, from: data) { profiles = decoded }
|
||||
if profiles.isEmpty { profiles = [ConnectionProfile()] }
|
||||
selectedID = profiles.first?.id
|
||||
}
|
||||
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 { return }
|
||||
|
||||
let password = PasswordStore.read(profile.id)
|
||||
let escaped = database.replacingOccurrences(of: "`", with: "``")
|
||||
|
||||
Task {
|
||||
do {
|
||||
let parsed = try await NativeMySQLRunner.run(
|
||||
profile: profile,
|
||||
database: "",
|
||||
password: password,
|
||||
sql: "SHOW TABLES FROM `\\(escaped)`"
|
||||
)
|
||||
|
||||
schemaTables[database] = parsed.rows.compactMap { $0.first }
|
||||
} catch {
|
||||
status = "Unable to load tables: \\(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func selectTable(database: String, table: String) {
|
||||
let db = database.replacingOccurrences(of: "`", with: "``")
|
||||
let tbl = table.replacingOccurrences(of: "`", with: "``")
|
||||
|
||||
sql = "SELECT * FROM `\\(db)`.`\\(tbl)` LIMIT 100;"
|
||||
runSQL()
|
||||
}
|
||||
|
||||
func loadDatabases() { execute("SHOW DATABASES;", listingDatabases: true) }
|
||||
|
||||
func runSQL() {
|
||||
formatSQL()
|
||||
execute(sql, listingDatabases: false)
|
||||
}
|
||||
private func execute(_ statement: String, listingDatabases: Bool) {
|
||||
guard let profile = selected else { status = "Select a connection"; return }
|
||||
guard !statement.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
|
||||
isRunning = true
|
||||
status = "Connecting…"
|
||||
let activeDB = listingDatabases ? "" : (selectedDatabase.isEmpty ? profile.database : selectedDatabase)
|
||||
let password = PasswordStore.read(profile.id)
|
||||
Task {
|
||||
do {
|
||||
let parsed = try await NativeMySQLRunner.run(profile: profile, database: activeDB, password: password, sql: statement)
|
||||
if listingDatabases {
|
||||
databases = parsed.rows.compactMap { $0.first }
|
||||
status = "Connected to \(profile.name)"
|
||||
} else {
|
||||
result = parsed
|
||||
status = "Query completed (\(parsed.rows.count) rows)"
|
||||
}
|
||||
} catch {
|
||||
status = "Error"
|
||||
result = QueryResult(message: error.localizedDescription)
|
||||
}
|
||||
isRunning = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user