730 lines
26 KiB
Swift
730 lines
26 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
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
@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]] = [:]
|
|
@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()] }
|
|
selectedID = profiles.first?.id
|
|
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
|
|
|
|
// 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])")
|
|
}
|
|
schemaColumns[database] = names.sorted()
|
|
} catch {
|
|
schemaColumns[database] = []
|
|
}
|
|
|
|
status = "Loaded \(tables.count) tables and \(views.count) views from \(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)
|
|
}
|
|
}
|
|
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
|
|
}
|
|
}
|