Skip to content

Commit 940ea14

Browse files
authored
perf: fix remaining medium and low severity performance issues (#370)
* 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<TableReference> 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) * 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 * fix: invalidate queryBuildingDriverCache in setEnabled()
1 parent 66bf0a7 commit 940ea14

20 files changed

+175
-73
lines changed

Plugins/TableProPluginKit/PluginDatabaseDriver.swift

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -269,41 +269,55 @@ public extension PluginDatabaseDriver {
269269
}
270270

271271
private static func substituteQuestionMarks(query: String, parameters: [String?]) -> String {
272+
let nsQuery = query as NSString
273+
let length = nsQuery.length
272274
var sql = ""
273275
var paramIndex = 0
274276
var inSingleQuote = false
275277
var inDoubleQuote = false
276278
var isEscaped = false
279+
var i = 0
280+
281+
let backslash: UInt16 = 0x5C // \\
282+
let singleQuote: UInt16 = 0x27 // '
283+
let doubleQuote: UInt16 = 0x22 // "
284+
let questionMark: UInt16 = 0x3F // ?
285+
286+
while i < length {
287+
let char = nsQuery.character(at: i)
277288

278-
for char in query {
279289
if isEscaped {
280290
isEscaped = false
281-
sql.append(char)
291+
sql.append(Character(UnicodeScalar(char)!))
292+
i += 1
282293
continue
283294
}
284295

285-
if char == "\\" && (inSingleQuote || inDoubleQuote) {
296+
if char == backslash && (inSingleQuote || inDoubleQuote) {
286297
isEscaped = true
287-
sql.append(char)
298+
sql.append(Character(UnicodeScalar(char)!))
299+
i += 1
288300
continue
289301
}
290302

291-
if char == "'" && !inDoubleQuote {
303+
if char == singleQuote && !inDoubleQuote {
292304
inSingleQuote.toggle()
293-
} else if char == "\"" && !inSingleQuote {
305+
} else if char == doubleQuote && !inSingleQuote {
294306
inDoubleQuote.toggle()
295307
}
296308

297-
if char == "?" && !inSingleQuote && !inDoubleQuote && paramIndex < parameters.count {
309+
if char == questionMark && !inSingleQuote && !inDoubleQuote && paramIndex < parameters.count {
298310
if let value = parameters[paramIndex] {
299311
sql.append(escapedParameterValue(value))
300312
} else {
301313
sql.append("NULL")
302314
}
303315
paramIndex += 1
304316
} else {
305-
sql.append(char)
317+
sql.append(Character(UnicodeScalar(char)!))
306318
}
319+
320+
i += 1
307321
}
308322

309323
return sql

TablePro/Core/AI/InlineSuggestionManager.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -430,6 +430,7 @@ final class InlineSuggestionManager {
430430
object: contentView,
431431
queue: .main
432432
) { [weak self] _ in
433+
guard self?.currentSuggestion != nil else { return }
433434
Task { @MainActor [weak self] in
434435
guard let self else { return }
435436
if let suggestion = self.currentSuggestion {

TablePro/Core/Autocomplete/SQLContextAnalyzer.swift

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ enum SQLClauseType {
4141
}
4242

4343
/// Represents a table reference with optional alias
44-
struct TableReference: Equatable, Sendable {
44+
internal struct TableReference: Hashable, Sendable {
4545
let tableName: String
4646
let alias: String?
4747

@@ -344,22 +344,23 @@ final class SQLContextAnalyzer {
344344

345345
// Find all table references in the current statement
346346
var tableReferences = extractTableReferences(from: currentStatement)
347+
var seenReferences = Set<TableReference>(tableReferences)
347348

348349
// Extract CTEs from the current statement
349350
let cteNames = extractCTENames(from: currentStatement)
350351

351352
// Add CTE names as table references
352353
for cteName in cteNames {
353354
let cteRef = TableReference(tableName: cteName, alias: nil)
354-
if !tableReferences.contains(cteRef) {
355+
if seenReferences.insert(cteRef).inserted {
355356
tableReferences.append(cteRef)
356357
}
357358
}
358359

359360
// Extract ALTER TABLE table name and add to references
360361
if let alterTableName = extractAlterTableName(from: currentStatement) {
361362
let alterRef = TableReference(tableName: alterTableName, alias: nil)
362-
if !tableReferences.contains(alterRef) {
363+
if seenReferences.insert(alterRef).inserted {
363364
tableReferences.append(alterRef)
364365
}
365366
}
@@ -782,6 +783,7 @@ final class SQLContextAnalyzer {
782783
/// Extract all table references (table names and aliases) from the query
783784
private func extractTableReferences(from query: String) -> [TableReference] {
784785
var references: [TableReference] = []
786+
var seen = Set<TableReference>()
785787

786788
// SQL keywords that should NOT be treated as table names
787789
let sqlKeywords: Set<String> = [
@@ -816,7 +818,7 @@ final class SQLContextAnalyzer {
816818
}
817819

818820
let ref = TableReference(tableName: tableName, alias: alias)
819-
if !references.contains(ref) {
821+
if seen.insert(ref).inserted {
820822
references.append(ref)
821823
}
822824
}

TablePro/Core/Autocomplete/SQLSchemaProvider.swift

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -118,23 +118,25 @@ actor SQLSchemaProvider {
118118

119119
/// Find table name from alias
120120
func resolveAlias(_ aliasOrName: String, in references: [TableReference]) -> String? {
121+
let lowerName = aliasOrName.lowercased()
122+
121123
// First check if it's an alias
122124
for ref in references {
123-
if ref.alias?.lowercased() == aliasOrName.lowercased() {
125+
if ref.alias?.lowercased() == lowerName {
124126
return ref.tableName
125127
}
126128
}
127129

128130
// Then check if it's a table name directly
129131
for ref in references {
130-
if ref.tableName.lowercased() == aliasOrName.lowercased() {
132+
if ref.tableName.lowercased() == lowerName {
131133
return ref.tableName
132134
}
133135
}
134136

135137
// Finally check against known tables
136138
for table in tables {
137-
if table.name.lowercased() == aliasOrName.lowercased() {
139+
if table.name.lowercased() == lowerName {
138140
return table.name
139141
}
140142
}

TablePro/Core/Plugins/PluginDriverAdapter.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable {
1111
let connection: DatabaseConnection
1212
private(set) var status: ConnectionStatus = .disconnected
1313
private let pluginDriver: any PluginDatabaseDriver
14+
private var columnTypeCache: [String: ColumnType] = [:]
1415

1516
var serverVersion: String? { pluginDriver.serverVersion }
1617
var parameterStyle: ParameterStyle { pluginDriver.parameterStyle }
@@ -421,6 +422,13 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable {
421422
}
422423

423424
private func mapColumnType(rawTypeName: String) -> ColumnType {
425+
if let cached = columnTypeCache[rawTypeName] { return cached }
426+
let result = classifyColumnType(rawTypeName: rawTypeName)
427+
columnTypeCache[rawTypeName] = result
428+
return result
429+
}
430+
431+
private func classifyColumnType(rawTypeName: String) -> ColumnType {
424432
let upper = rawTypeName.uppercased()
425433

426434
if upper.contains("BOOL") {

TablePro/Core/Plugins/PluginManager.swift

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ final class PluginManager {
5757

5858
private var pendingPluginURLs: [(url: URL, source: PluginSource)] = []
5959

60+
private var queryBuildingDriverCache: [String: (any PluginDatabaseDriver)?] = [:]
61+
6062
private init() {}
6163

6264
private func migrateDisabledPluginsKey() {
@@ -207,6 +209,8 @@ final class PluginManager {
207209

208210
Self.logger.info("Loaded plugin '\(entry.name)' v\(entry.version) [\(item.source == .builtIn ? "built-in" : "user")]")
209211
}
212+
213+
queryBuildingDriverCache.removeAll()
210214
}
211215

212216
private func discoverAllPlugins() {
@@ -247,6 +251,7 @@ final class PluginManager {
247251
}
248252
}
249253

254+
queryBuildingDriverCache.removeAll()
250255
hasFinishedInitialLoad = true
251256
validateDependencies()
252257
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)")
@@ -622,13 +627,23 @@ final class PluginManager {
622627
/// Returns a temporary plugin driver for query building (buildBrowseQuery), or nil
623628
/// if the plugin doesn't implement custom query building (NoSQL hooks).
624629
func queryBuildingDriver(for databaseType: DatabaseType) -> (any PluginDatabaseDriver)? {
625-
guard let plugin = driverPlugin(for: databaseType) else { return nil }
630+
let typeId = databaseType.pluginTypeId
631+
if let cached = queryBuildingDriverCache[typeId] { return cached }
632+
guard let plugin = driverPlugin(for: databaseType) else {
633+
if hasFinishedInitialLoad {
634+
queryBuildingDriverCache[typeId] = .some(nil)
635+
}
636+
return nil
637+
}
626638
let config = DriverConnectionConfig(host: "", port: 0, username: "", password: "", database: "")
627639
let driver = plugin.createDriver(config: config)
628-
guard driver.buildBrowseQuery(table: "_probe", sortColumns: [], columns: [], limit: 1, offset: 0) != nil else {
629-
return nil
640+
let result: (any PluginDatabaseDriver)? =
641+
driver.buildBrowseQuery(table: "_probe", sortColumns: [], columns: [], limit: 1, offset: 0) != nil
642+
? driver : nil
643+
if hasFinishedInitialLoad {
644+
queryBuildingDriverCache[typeId] = .some(result)
630645
}
631-
return driver
646+
return result
632647
}
633648

634649
func editorLanguage(for databaseType: DatabaseType) -> EditorLanguage {
@@ -856,6 +871,7 @@ final class PluginManager {
856871
unregisterCapabilities(pluginId: pluginId)
857872
}
858873

874+
queryBuildingDriverCache.removeAll()
859875
Self.logger.info("Plugin '\(pluginId)' \(enabled ? "enabled" : "disabled")")
860876
}
861877

@@ -1005,6 +1021,8 @@ final class PluginManager {
10051021
disabled.remove(id)
10061022
disabledPluginIds = disabled
10071023

1024+
queryBuildingDriverCache.removeAll()
1025+
10081026
Self.logger.info("Uninstalled plugin '\(id)'")
10091027
_needsRestart = true
10101028
}

TablePro/Core/SSH/LibSSH2Tunnel.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -358,7 +358,7 @@ internal final class LibSSH2Tunnel: @unchecked Sendable {
358358
pollfd(fd: self.socketFD, events: Int16(POLLIN), revents: 0),
359359
]
360360

361-
let pollResult = poll(&pollFDs, 2, 100) // 100ms timeout
361+
let pollResult = poll(&pollFDs, 2, 500) // 500ms timeout
362362
if pollResult < 0 { break }
363363

364364
// Read from SSH channel when the SSH socket has data or on timeout

TablePro/Core/SSH/LibSSH2TunnelFactory.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -485,7 +485,7 @@ internal enum LibSSH2TunnelFactory {
485485
pollfd(fd: sshSocketFD, events: Int16(POLLIN), revents: 0),
486486
]
487487

488-
let pollResult = poll(&pollFDs, 2, 100)
488+
let pollResult = poll(&pollFDs, 2, 500)
489489
if pollResult < 0 { break }
490490

491491
// Channel -> socketpair (serialized libssh2 call)

TablePro/Core/Services/Formatting/SQLFormatterService.swift

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,10 @@ struct SQLFormatterService: SQLFormatterProtocol {
9292
}()
9393

9494
/// WHERE condition alignment pattern: \s+(AND|OR)\s+
95+
private static let majorKeywordRegex: NSRegularExpression = {
96+
regex("\\b(ORDER|GROUP|HAVING|LIMIT|UNION|INTERSECT)\\b", options: .caseInsensitive)
97+
}()
98+
9599
private static let whereConditionRegex: NSRegularExpression = {
96100
regex("\\s+(AND|OR)\\s+", options: .caseInsensitive)
97101
}()
@@ -471,14 +475,14 @@ struct SQLFormatterService: SQLFormatterProtocol {
471475
return sql
472476
}
473477

474-
// Find end of WHERE clause
475-
let majorKeywords = ["ORDER", "GROUP", "HAVING", "LIMIT", "UNION", "INTERSECT"]
478+
// Find end of WHERE clause using single regex scan
479+
let searchStart = whereRange.upperBound
480+
let searchNSRange = NSRange(searchStart..<sql.endIndex, in: sql)
476481
var endIndex = sql.endIndex
477482

478-
for keyword in majorKeywords {
479-
if let range = sql.range(of: keyword, options: .caseInsensitive, range: whereRange.upperBound..<sql.endIndex) {
480-
endIndex = min(endIndex, range.lowerBound)
481-
}
483+
if let match = Self.majorKeywordRegex.firstMatch(in: sql, range: searchNSRange),
484+
let matchRange = Range(match.range, in: sql) {
485+
endIndex = matchRange.lowerBound
482486
}
483487

484488
// Fix #3: Work with immutable substring

TablePro/Core/Services/Query/RowOperationsManager.swift

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -394,12 +394,41 @@ final class RowOperationsManager {
394394
/// Auto-detect whether clipboard text is CSV or TSV
395395
/// Heuristic: if tabs appear in most lines, use TSV; otherwise CSV
396396
static func detectParser(for text: String) -> RowDataParser {
397-
let lines = text.components(separatedBy: .newlines)
398-
.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty }
399-
guard !lines.isEmpty else { return TSVRowParser() }
397+
// Single-pass scan: count non-empty lines containing tabs vs commas
398+
var tabLines = 0
399+
var commaLines = 0
400+
var nonEmptyLines = 0
401+
var lineHasTab = false
402+
var lineHasComma = false
403+
var lineIsEmpty = true
404+
405+
for char in text {
406+
if char.isNewline {
407+
if !lineIsEmpty {
408+
nonEmptyLines += 1
409+
if lineHasTab { tabLines += 1 }
410+
if lineHasComma { commaLines += 1 }
411+
}
412+
lineHasTab = false
413+
lineHasComma = false
414+
lineIsEmpty = true
415+
} else {
416+
if !char.isWhitespace { lineIsEmpty = false }
417+
if char == "\t" { lineHasTab = true }
418+
if char == "," { lineHasComma = true }
419+
}
420+
}
421+
// Handle last line (no trailing newline)
422+
if !lineIsEmpty {
423+
nonEmptyLines += 1
424+
if lineHasTab { tabLines += 1 }
425+
if lineHasComma { commaLines += 1 }
426+
}
427+
428+
guard nonEmptyLines > 0 else { return TSVRowParser() }
400429

401-
let tabCount = lines.count(where: { $0.contains("\t") })
402-
let commaCount = lines.count(where: { $0.contains(",") })
430+
let tabCount = tabLines
431+
let commaCount = commaLines
403432

404433
// If majority of lines have tabs, use TSV; otherwise CSV
405434
if tabCount > commaCount {

0 commit comments

Comments
 (0)