From e24b15e4b54d68f10fa256a70228978798bb63f8 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 18 Mar 2026 22:35:37 +0700 Subject: [PATCH 1/3] perf: fix remaining medium and low severity performance issues Medium fixes: - Coalesce NSWindow.didUpdateNotification into one check per run loop (MED-2) - Increase SSH relay poll timeout from 100ms to 500ms (MED-3) - Skip tab persistence on column-layout-only changes (MED-4) - Use toQueryResultRows() with reserveCapacity (MED-9) - Add single-column sort fast path avoiding key pre-extraction (MED-17) Low fixes: - Cache queryBuildingDriver probe result per database type (LOW-2) - Cache column type classification in PluginDriverAdapter (LOW-3) - Use NSString character-at-index in substituteQuestionMarks (LOW-4) - Remove redundant onChange(of: tabs.count) observer (LOW-5) - Replace inline tabs.map(\.id) with tracked tabIds property (LOW-6) - Count newlines without array allocation in CellOverlayEditor (LOW-7) - Single-pass delimiter detection in RowOperationsManager (LOW-7) - Replace 6x linear string scans with single alternation regex (LOW-8) - Lowercase aliasOrName once in resolveAlias (LOW-9) - Use Set for O(1) dedup in autocomplete (LOW-10) - Consolidate 40+ keyword regexes into single alternation per color (LOW-11) - In-place index mutation in removeRow instead of .map (LOW-12) - Guard scroll observer when no inline suggestion active (LOW-13) --- .../PluginDatabaseDriver.swift | 30 ++++++++++---- .../Core/AI/InlineSuggestionManager.swift | 1 + .../Autocomplete/SQLContextAnalyzer.swift | 10 +++-- .../Core/Autocomplete/SQLSchemaProvider.swift | 8 ++-- .../Core/Plugins/PluginDriverAdapter.swift | 8 ++++ TablePro/Core/Plugins/PluginManager.swift | 18 ++++++--- TablePro/Core/SSH/LibSSH2Tunnel.swift | 2 +- TablePro/Core/SSH/LibSSH2TunnelFactory.swift | 2 +- .../Formatting/SQLFormatterService.swift | 15 ++++--- .../Services/Query/RowOperationsManager.swift | 39 ++++++++++++++++--- TablePro/Models/Query/QueryResult.swift | 7 +++- TablePro/Models/Query/QueryTab.swift | 2 + TablePro/Models/Query/RowProvider.swift | 4 +- .../Components/HighlightedSQLTextView.swift | 18 ++++----- TablePro/Views/Editor/EditorEventRouter.swift | 18 +++++---- .../Main/Child/MainEditorContentView.swift | 13 ++----- .../Views/Main/MainContentCoordinator.swift | 20 ++++++++-- TablePro/Views/Main/MainContentView.swift | 1 + .../Views/Results/CellOverlayEditor.swift | 6 ++- TablePro/Views/Structure/DDLTextView.swift | 9 ++--- 20 files changed, 159 insertions(+), 72 deletions(-) diff --git a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift index bcad7f7b7..10f62e5f1 100644 --- a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift +++ b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift @@ -269,32 +269,44 @@ public extension PluginDatabaseDriver { } private static func substituteQuestionMarks(query: String, parameters: [String?]) -> String { + let nsQuery = query as NSString + let length = nsQuery.length var sql = "" var paramIndex = 0 var inSingleQuote = false var inDoubleQuote = false var isEscaped = false + var i = 0 + + let backslash: UInt16 = 0x5C // \\ + let singleQuote: UInt16 = 0x27 // ' + let doubleQuote: UInt16 = 0x22 // " + let questionMark: UInt16 = 0x3F // ? + + while i < length { + let char = nsQuery.character(at: i) - for char in query { if isEscaped { isEscaped = false - sql.append(char) + sql.append(Character(UnicodeScalar(char)!)) + i += 1 continue } - if char == "\\" && (inSingleQuote || inDoubleQuote) { + if char == backslash && (inSingleQuote || inDoubleQuote) { isEscaped = true - sql.append(char) + sql.append(Character(UnicodeScalar(char)!)) + i += 1 continue } - if char == "'" && !inDoubleQuote { + if char == singleQuote && !inDoubleQuote { inSingleQuote.toggle() - } else if char == "\"" && !inSingleQuote { + } else if char == doubleQuote && !inSingleQuote { inDoubleQuote.toggle() } - if char == "?" && !inSingleQuote && !inDoubleQuote && paramIndex < parameters.count { + if char == questionMark && !inSingleQuote && !inDoubleQuote && paramIndex < parameters.count { if let value = parameters[paramIndex] { sql.append(escapedParameterValue(value)) } else { @@ -302,8 +314,10 @@ public extension PluginDatabaseDriver { } paramIndex += 1 } else { - sql.append(char) + sql.append(Character(UnicodeScalar(char)!)) } + + i += 1 } return sql diff --git a/TablePro/Core/AI/InlineSuggestionManager.swift b/TablePro/Core/AI/InlineSuggestionManager.swift index 536cf28ba..e66a033df 100644 --- a/TablePro/Core/AI/InlineSuggestionManager.swift +++ b/TablePro/Core/AI/InlineSuggestionManager.swift @@ -430,6 +430,7 @@ final class InlineSuggestionManager { object: contentView, queue: .main ) { [weak self] _ in + guard self?.currentSuggestion != nil else { return } Task { @MainActor [weak self] in guard let self else { return } if let suggestion = self.currentSuggestion { diff --git a/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift b/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift index 044317805..b1428e445 100644 --- a/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift +++ b/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift @@ -41,7 +41,7 @@ enum SQLClauseType { } /// Represents a table reference with optional alias -struct TableReference: Equatable, Sendable { +struct TableReference: Hashable, Sendable { let tableName: String let alias: String? @@ -344,6 +344,7 @@ final class SQLContextAnalyzer { // Find all table references in the current statement var tableReferences = extractTableReferences(from: currentStatement) + var seenReferences = Set(tableReferences) // Extract CTEs from the current statement let cteNames = extractCTENames(from: currentStatement) @@ -351,7 +352,7 @@ final class SQLContextAnalyzer { // Add CTE names as table references for cteName in cteNames { let cteRef = TableReference(tableName: cteName, alias: nil) - if !tableReferences.contains(cteRef) { + if seenReferences.insert(cteRef).inserted { tableReferences.append(cteRef) } } @@ -359,7 +360,7 @@ final class SQLContextAnalyzer { // Extract ALTER TABLE table name and add to references if let alterTableName = extractAlterTableName(from: currentStatement) { let alterRef = TableReference(tableName: alterTableName, alias: nil) - if !tableReferences.contains(alterRef) { + if seenReferences.insert(alterRef).inserted { tableReferences.append(alterRef) } } @@ -782,6 +783,7 @@ final class SQLContextAnalyzer { /// Extract all table references (table names and aliases) from the query private func extractTableReferences(from query: String) -> [TableReference] { var references: [TableReference] = [] + var seen = Set() // SQL keywords that should NOT be treated as table names let sqlKeywords: Set = [ @@ -816,7 +818,7 @@ final class SQLContextAnalyzer { } let ref = TableReference(tableName: tableName, alias: alias) - if !references.contains(ref) { + if seen.insert(ref).inserted { references.append(ref) } } diff --git a/TablePro/Core/Autocomplete/SQLSchemaProvider.swift b/TablePro/Core/Autocomplete/SQLSchemaProvider.swift index aad57abe0..d84aff10c 100644 --- a/TablePro/Core/Autocomplete/SQLSchemaProvider.swift +++ b/TablePro/Core/Autocomplete/SQLSchemaProvider.swift @@ -118,23 +118,25 @@ actor SQLSchemaProvider { /// Find table name from alias func resolveAlias(_ aliasOrName: String, in references: [TableReference]) -> String? { + let lowerName = aliasOrName.lowercased() + // First check if it's an alias for ref in references { - if ref.alias?.lowercased() == aliasOrName.lowercased() { + if ref.alias?.lowercased() == lowerName { return ref.tableName } } // Then check if it's a table name directly for ref in references { - if ref.tableName.lowercased() == aliasOrName.lowercased() { + if ref.tableName.lowercased() == lowerName { return ref.tableName } } // Finally check against known tables for table in tables { - if table.name.lowercased() == aliasOrName.lowercased() { + if table.name.lowercased() == lowerName { return table.name } } diff --git a/TablePro/Core/Plugins/PluginDriverAdapter.swift b/TablePro/Core/Plugins/PluginDriverAdapter.swift index 3c1dc99a1..c435898a4 100644 --- a/TablePro/Core/Plugins/PluginDriverAdapter.swift +++ b/TablePro/Core/Plugins/PluginDriverAdapter.swift @@ -11,6 +11,7 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable { let connection: DatabaseConnection private(set) var status: ConnectionStatus = .disconnected private let pluginDriver: any PluginDatabaseDriver + private var columnTypeCache: [String: ColumnType] = [:] var serverVersion: String? { pluginDriver.serverVersion } var parameterStyle: ParameterStyle { pluginDriver.parameterStyle } @@ -421,6 +422,13 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable { } private func mapColumnType(rawTypeName: String) -> ColumnType { + if let cached = columnTypeCache[rawTypeName] { return cached } + let result = classifyColumnType(rawTypeName: rawTypeName) + columnTypeCache[rawTypeName] = result + return result + } + + private func classifyColumnType(rawTypeName: String) -> ColumnType { let upper = rawTypeName.uppercased() if upper.contains("BOOL") { diff --git a/TablePro/Core/Plugins/PluginManager.swift b/TablePro/Core/Plugins/PluginManager.swift index dff69bcaa..67f7b529b 100644 --- a/TablePro/Core/Plugins/PluginManager.swift +++ b/TablePro/Core/Plugins/PluginManager.swift @@ -57,6 +57,8 @@ final class PluginManager { private var pendingPluginURLs: [(url: URL, source: PluginSource)] = [] + private var queryBuildingDriverCache: [String: (any PluginDatabaseDriver)?] = [:] + private init() {} private func migrateDisabledPluginsKey() { @@ -622,13 +624,19 @@ final class PluginManager { /// Returns a temporary plugin driver for query building (buildBrowseQuery), or nil /// if the plugin doesn't implement custom query building (NoSQL hooks). func queryBuildingDriver(for databaseType: DatabaseType) -> (any PluginDatabaseDriver)? { - guard let plugin = driverPlugin(for: databaseType) else { return nil } - let config = DriverConnectionConfig(host: "", port: 0, username: "", password: "", database: "") - let driver = plugin.createDriver(config: config) - guard driver.buildBrowseQuery(table: "_probe", sortColumns: [], columns: [], limit: 1, offset: 0) != nil else { + let typeId = databaseType.pluginTypeId + if let cached = queryBuildingDriverCache[typeId] { return cached } + guard let plugin = driverPlugin(for: databaseType) else { + queryBuildingDriverCache[typeId] = .some(nil) return nil } - return driver + let config = DriverConnectionConfig(host: "", port: 0, username: "", password: "", database: "") + let driver = plugin.createDriver(config: config) + let result: (any PluginDatabaseDriver)? = + driver.buildBrowseQuery(table: "_probe", sortColumns: [], columns: [], limit: 1, offset: 0) != nil + ? driver : nil + queryBuildingDriverCache[typeId] = .some(result) + return result } func editorLanguage(for databaseType: DatabaseType) -> EditorLanguage { diff --git a/TablePro/Core/SSH/LibSSH2Tunnel.swift b/TablePro/Core/SSH/LibSSH2Tunnel.swift index 026791640..888f99ef3 100644 --- a/TablePro/Core/SSH/LibSSH2Tunnel.swift +++ b/TablePro/Core/SSH/LibSSH2Tunnel.swift @@ -358,7 +358,7 @@ internal final class LibSSH2Tunnel: @unchecked Sendable { pollfd(fd: self.socketFD, events: Int16(POLLIN), revents: 0), ] - let pollResult = poll(&pollFDs, 2, 100) // 100ms timeout + let pollResult = poll(&pollFDs, 2, 500) // 500ms timeout if pollResult < 0 { break } // Read from SSH channel when the SSH socket has data or on timeout diff --git a/TablePro/Core/SSH/LibSSH2TunnelFactory.swift b/TablePro/Core/SSH/LibSSH2TunnelFactory.swift index 10ab2540e..9df69e7ab 100644 --- a/TablePro/Core/SSH/LibSSH2TunnelFactory.swift +++ b/TablePro/Core/SSH/LibSSH2TunnelFactory.swift @@ -485,7 +485,7 @@ internal enum LibSSH2TunnelFactory { pollfd(fd: sshSocketFD, events: Int16(POLLIN), revents: 0), ] - let pollResult = poll(&pollFDs, 2, 100) + let pollResult = poll(&pollFDs, 2, 500) if pollResult < 0 { break } // Channel -> socketpair (serialized libssh2 call) diff --git a/TablePro/Core/Services/Formatting/SQLFormatterService.swift b/TablePro/Core/Services/Formatting/SQLFormatterService.swift index df79650ad..edd59767d 100644 --- a/TablePro/Core/Services/Formatting/SQLFormatterService.swift +++ b/TablePro/Core/Services/Formatting/SQLFormatterService.swift @@ -92,6 +92,10 @@ struct SQLFormatterService: SQLFormatterProtocol { }() /// WHERE condition alignment pattern: \s+(AND|OR)\s+ + private static let majorKeywordRegex: NSRegularExpression = { + regex("\\b(ORDER|GROUP|HAVING|LIMIT|UNION|INTERSECT)\\b", options: .caseInsensitive) + }() + private static let whereConditionRegex: NSRegularExpression = { regex("\\s+(AND|OR)\\s+", options: .caseInsensitive) }() @@ -471,14 +475,13 @@ struct SQLFormatterService: SQLFormatterProtocol { return sql } - // Find end of WHERE clause - let majorKeywords = ["ORDER", "GROUP", "HAVING", "LIMIT", "UNION", "INTERSECT"] + // Find end of WHERE clause using single regex scan + let searchStart = whereRange.upperBound + let searchNSRange = NSRange(searchStart.. RowDataParser { - let lines = text.components(separatedBy: .newlines) - .filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty } - guard !lines.isEmpty else { return TSVRowParser() } + // Single-pass scan: count non-empty lines containing tabs vs commas + var tabLines = 0 + var commaLines = 0 + var nonEmptyLines = 0 + var lineHasTab = false + var lineHasComma = false + var lineIsEmpty = true + + for char in text { + if char.isNewline { + if !lineIsEmpty { + nonEmptyLines += 1 + if lineHasTab { tabLines += 1 } + if lineHasComma { commaLines += 1 } + } + lineHasTab = false + lineHasComma = false + lineIsEmpty = true + } else { + if !char.isWhitespace { lineIsEmpty = false } + if char == "\t" { lineHasTab = true } + if char == "," { lineHasComma = true } + } + } + // Handle last line (no trailing newline) + if !lineIsEmpty { + nonEmptyLines += 1 + if lineHasTab { tabLines += 1 } + if lineHasComma { commaLines += 1 } + } + + guard nonEmptyLines > 0 else { return TSVRowParser() } - let tabCount = lines.count(where: { $0.contains("\t") }) - let commaCount = lines.count(where: { $0.contains(",") }) + let tabCount = tabLines + let commaCount = commaLines // If majority of lines have tabs, use TSV; otherwise CSV if tabCount > commaCount { diff --git a/TablePro/Models/Query/QueryResult.swift b/TablePro/Models/Query/QueryResult.swift index f79ecc6d6..2f2b0ccdc 100644 --- a/TablePro/Models/Query/QueryResult.swift +++ b/TablePro/Models/Query/QueryResult.swift @@ -43,9 +43,12 @@ struct QueryResult { /// Convert to QueryResultRow format for UI func toQueryResultRows() -> [QueryResultRow] { - rows.enumerated().map { index, row in - QueryResultRow(id: index, values: row) + var result = [QueryResultRow]() + result.reserveCapacity(rows.count) + for (index, row) in rows.enumerated() { + result.append(QueryResultRow(id: index, values: row)) } + return result } static let empty = QueryResult( diff --git a/TablePro/Models/Query/QueryTab.swift b/TablePro/Models/Query/QueryTab.swift index 170b00ac6..1ea835121 100644 --- a/TablePro/Models/Query/QueryTab.swift +++ b/TablePro/Models/Query/QueryTab.swift @@ -517,6 +517,8 @@ final class QueryTabManager { var tabs: [QueryTab] = [] var selectedTabId: UUID? + var tabIds: [UUID] { tabs.map(\.id) } + var selectedTab: QueryTab? { guard let id = selectedTabId else { return tabs.first } return tabs.first { $0.id == id } diff --git a/TablePro/Models/Query/RowProvider.swift b/TablePro/Models/Query/RowProvider.swift index 2bfc9bdb6..b3d8be5ca 100644 --- a/TablePro/Models/Query/RowProvider.swift +++ b/TablePro/Models/Query/RowProvider.swift @@ -212,7 +212,9 @@ final class InMemoryRowProvider: RowProvider { // Rebuild sort indices: remove this entry and adjust indices above the removed one var newIndices = sorted newIndices.remove(at: index) - newIndices = newIndices.map { $0 > bufferIdx ? $0 - 1 : $0 } + for i in newIndices.indices where newIndices[i] > bufferIdx { + newIndices[i] -= 1 + } sortIndices = newIndices } else { rowBuffer.rows.remove(at: index) diff --git a/TablePro/Views/Components/HighlightedSQLTextView.swift b/TablePro/Views/Components/HighlightedSQLTextView.swift index 75514ba5c..ed702ddac 100644 --- a/TablePro/Views/Components/HighlightedSQLTextView.swift +++ b/TablePro/Views/Components/HighlightedSQLTextView.swift @@ -69,7 +69,7 @@ struct HighlightedSQLTextView: NSViewRepresentable { private static let syntaxPatterns: [(regex: NSRegularExpression, color: NSColor)] = { var patterns: [(NSRegularExpression, NSColor)] = [] - // SQL Keywords (blue) + // SQL Keywords (blue) — single alternation regex for all keywords let keywords = [ "CREATE", "TABLE", "PRIMARY", "KEY", "FOREIGN", "REFERENCES", "NOT", "NULL", "DEFAULT", "UNIQUE", "INDEX", "AUTO_INCREMENT", @@ -82,10 +82,9 @@ struct HighlightedSQLTextView: NSViewRepresentable { "SUM", "AVG", "MIN", "MAX", "CASE", "WHEN", "THEN", "ELSE", "END", "UNION", "ALL", "WITH", "RECURSIVE" ] - for keyword in keywords { - if let regex = try? NSRegularExpression(pattern: "\\b\(keyword)\\b", options: .caseInsensitive) { - patterns.append((regex, .systemBlue)) - } + let keywordPattern = "\\b(" + keywords.joined(separator: "|") + ")\\b" + if let regex = try? NSRegularExpression(pattern: keywordPattern, options: .caseInsensitive) { + patterns.append((regex, .systemBlue)) } // Strings (red) @@ -111,17 +110,16 @@ struct HighlightedSQLTextView: NSViewRepresentable { private static let mqlPatterns: [(regex: NSRegularExpression, color: NSColor)] = { var patterns: [(NSRegularExpression, NSColor)] = [] - // MongoDB methods (blue) + // MongoDB methods (blue) — single alternation regex for all methods let methods = [ "find", "findOne", "insertOne", "insertMany", "updateOne", "updateMany", "deleteOne", "deleteMany", "aggregate", "countDocuments", "estimatedDocumentCount", "distinct", "createIndex", "dropIndex", "sort", "limit", "skip", "project", "match", "group", "unwind", "lookup", "replaceOne", "drop" ] - for method in methods { - if let regex = try? NSRegularExpression(pattern: "\\.(\(method))\\s*\\(", options: []) { - patterns.append((regex, .systemBlue)) - } + let methodPattern = "\\.(" + methods.joined(separator: "|") + ")\\s*\\(" + if let regex = try? NSRegularExpression(pattern: methodPattern, options: []) { + patterns.append((regex, .systemBlue)) } // db. prefix (blue) diff --git a/TablePro/Views/Editor/EditorEventRouter.swift b/TablePro/Views/Editor/EditorEventRouter.swift index ecb4d91f7..42616badf 100644 --- a/TablePro/Views/Editor/EditorEventRouter.swift +++ b/TablePro/Views/Editor/EditorEventRouter.swift @@ -22,6 +22,7 @@ internal final class EditorEventRouter { private var rightClickMonitor: Any? private var clipboardMonitor: Any? private var windowUpdateObserver: NSObjectProtocol? + private var needsFirstResponderCheck = false private init() {} @@ -84,10 +85,18 @@ internal final class EditorEventRouter { forName: NSWindow.didUpdateNotification, object: nil, queue: .main - ) { [weak self] notification in + ) { [weak self] _ in guard let self else { return } MainActor.assumeIsolated { - self.handleWindowUpdate(notification) + guard !self.needsFirstResponderCheck else { return } + self.needsFirstResponderCheck = true + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.needsFirstResponderCheck = false + for ref in self.editors.values { + ref.coordinator?.checkFirstResponderChange() + } + } } } } @@ -152,9 +161,4 @@ internal final class EditorEventRouter { return event } - private func handleWindowUpdate(_ notification: Notification) { - guard let window = notification.object as? NSWindow, - let (coordinator, _) = editor(for: window) else { return } - coordinator.checkFirstResponderChange() - } } diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index 4e6b215ad..820f71697 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -115,16 +115,7 @@ struct MainEditorContentView: View { initialQuery: item.query ) } - .onChange(of: tabManager.tabs.count) { - // Clean up caches for closed tabs - let openTabIds = Set(tabManager.tabs.map(\.id)) - sortCache = sortCache.filter { openTabIds.contains($0.key) } - coordinator.cleanupSortCache(openTabIds: openTabIds) - tabRowProviders = tabRowProviders.filter { openTabIds.contains($0.key) } - tabProviderVersions = tabProviderVersions.filter { openTabIds.contains($0.key) } - tabProviderMetaVersions = tabProviderMetaVersions.filter { openTabIds.contains($0.key) } - } - .onChange(of: tabManager.tabs.map(\.id)) { _, newIds in + .onChange(of: tabManager.tabIds) { _, newIds in let openTabIds = Set(newIds) sortCache = sortCache.filter { openTabIds.contains($0.key) } coordinator.cleanupSortCache(openTabIds: openTabIds) @@ -462,6 +453,8 @@ struct MainEditorContentView: View { Binding( get: { tab.columnLayout }, set: { newValue in + coordinator.isUpdatingColumnLayout = true + defer { coordinator.isUpdatingColumnLayout = false } if let index = tabManager.selectedTabIndex { tabManager.tabs[index].columnLayout = newValue } diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 3a3727f6c..af45d32f9 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -106,6 +106,7 @@ final class MainContentCoordinator { /// Set during handleTabChange to suppress redundant onChange(of: resultColumns) reconfiguration @ObservationIgnored internal var isHandlingTabSwitch = false + @ObservationIgnored var isUpdatingColumnLayout = false /// Guards against re-entrant confirm dialogs (e.g. nested run loop during runModal) @ObservationIgnored internal var isShowingConfirmAlert = false @@ -839,9 +840,7 @@ final class MainContentCoordinator { let result = try await queryDriver.execute(query: effectiveSQL) safeColumns = result.columns safeColumnTypes = result.columnTypes - safeRows = result.rows.enumerated().map { index, row in - QueryResultRow(id: index, values: row) - } + safeRows = result.toQueryResultRows() safeExecutionTime = result.executionTime safeRowsAffected = result.rowsAffected } @@ -1187,6 +1186,21 @@ final class MainContentCoordinator { rows: [QueryResultRow], sortColumns: [SortColumn] ) -> [Int] { + // Fast path: single-column sort avoids intermediate key array allocation + if sortColumns.count == 1 { + let col = sortColumns[0] + let colIndex = col.columnIndex + let ascending = col.direction == .ascending + var indices = Array(0.. Date: Thu, 19 Mar 2026 08:22:27 +0700 Subject: [PATCH 2/3] fix: address CodeRabbit review feedback - Remove force-unwrap in SQLFormatterService majorKeywordRegex match - Add explicit internal access control on TableReference - Cap DDL highlight regex to 10k characters for large DDL safety - Fix CRLF double-counting in CellOverlayEditor newline counter - Invalidate queryBuildingDriverCache on plugin register/uninstall - Use DispatchQueue.main.async for isUpdatingColumnLayout flag reset --- TablePro/Core/Autocomplete/SQLContextAnalyzer.swift | 2 +- TablePro/Core/Plugins/PluginManager.swift | 13 +++++++++++-- .../Services/Formatting/SQLFormatterService.swift | 5 +++-- .../Views/Main/Child/MainEditorContentView.swift | 4 +++- TablePro/Views/Results/CellOverlayEditor.swift | 4 ++-- TablePro/Views/Structure/DDLTextView.swift | 6 ++++-- 6 files changed, 24 insertions(+), 10 deletions(-) diff --git a/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift b/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift index b1428e445..122132048 100644 --- a/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift +++ b/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift @@ -41,7 +41,7 @@ enum SQLClauseType { } /// Represents a table reference with optional alias -struct TableReference: Hashable, Sendable { +internal struct TableReference: Hashable, Sendable { let tableName: String let alias: String? diff --git a/TablePro/Core/Plugins/PluginManager.swift b/TablePro/Core/Plugins/PluginManager.swift index 67f7b529b..92b99764b 100644 --- a/TablePro/Core/Plugins/PluginManager.swift +++ b/TablePro/Core/Plugins/PluginManager.swift @@ -209,6 +209,8 @@ final class PluginManager { Self.logger.info("Loaded plugin '\(entry.name)' v\(entry.version) [\(item.source == .builtIn ? "built-in" : "user")]") } + + queryBuildingDriverCache.removeAll() } private func discoverAllPlugins() { @@ -249,6 +251,7 @@ final class PluginManager { } } + queryBuildingDriverCache.removeAll() hasFinishedInitialLoad = true validateDependencies() Self.logger.info("Loaded \(self.plugins.count) plugin(s): \(self.driverPlugins.count) driver(s), \(self.exportPlugins.count) export format(s), \(self.importPlugins.count) import format(s)") @@ -627,7 +630,9 @@ final class PluginManager { let typeId = databaseType.pluginTypeId if let cached = queryBuildingDriverCache[typeId] { return cached } guard let plugin = driverPlugin(for: databaseType) else { - queryBuildingDriverCache[typeId] = .some(nil) + if hasFinishedInitialLoad { + queryBuildingDriverCache[typeId] = .some(nil) + } return nil } let config = DriverConnectionConfig(host: "", port: 0, username: "", password: "", database: "") @@ -635,7 +640,9 @@ final class PluginManager { let result: (any PluginDatabaseDriver)? = driver.buildBrowseQuery(table: "_probe", sortColumns: [], columns: [], limit: 1, offset: 0) != nil ? driver : nil - queryBuildingDriverCache[typeId] = .some(result) + if hasFinishedInitialLoad { + queryBuildingDriverCache[typeId] = .some(result) + } return result } @@ -1013,6 +1020,8 @@ final class PluginManager { disabled.remove(id) disabledPluginIds = disabled + queryBuildingDriverCache.removeAll() + Self.logger.info("Uninstalled plugin '\(id)'") _needsRestart = true } diff --git a/TablePro/Core/Services/Formatting/SQLFormatterService.swift b/TablePro/Core/Services/Formatting/SQLFormatterService.swift index edd59767d..6e6050872 100644 --- a/TablePro/Core/Services/Formatting/SQLFormatterService.swift +++ b/TablePro/Core/Services/Formatting/SQLFormatterService.swift @@ -480,8 +480,9 @@ struct SQLFormatterService: SQLFormatterProtocol { let searchNSRange = NSRange(searchStart.. Date: Thu, 19 Mar 2026 08:30:15 +0700 Subject: [PATCH 3/3] fix: invalidate queryBuildingDriverCache in setEnabled() --- TablePro/Core/Plugins/PluginManager.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/TablePro/Core/Plugins/PluginManager.swift b/TablePro/Core/Plugins/PluginManager.swift index 92b99764b..4090a2835 100644 --- a/TablePro/Core/Plugins/PluginManager.swift +++ b/TablePro/Core/Plugins/PluginManager.swift @@ -871,6 +871,7 @@ final class PluginManager { unregisterCapabilities(pluginId: pluginId) } + queryBuildingDriverCache.removeAll() Self.logger.info("Plugin '\(pluginId)' \(enabled ? "enabled" : "disabled")") }