Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- Fix high CPU/RAM on app launch from blocking storage init, unsynchronized health monitors, and excessive retry loops
- Fix O(n log n) row cache eviction in RowProvider by replacing sorted eviction with O(n) distance-threshold filter
- Fix O(n) string operations in GeometryWKBParser, RedisDriver, and autocomplete scoring by switching to NSString O(1) indexing
- Fix slow database switcher loading by replacing N+1 metadata queries with single batched queries (MySQL, PostgreSQL, Redshift)
- Fix slow Redis key browsing by pipelining TYPE and TTL commands in a single round trip instead of 3 sequential commands per key
- Fix slow SQL export startup by batching COUNT(*) queries via UNION ALL and batching dependent sequence/type lookups
Expand Down
25 changes: 9 additions & 16 deletions TablePro/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -501,17 +501,15 @@ class AppDelegate: NSObject, NSApplicationDelegate {
)
}

/// Schedule repeated checks to close any welcome window that
/// SwiftUI creates as part of app activation for a file-open event.
/// Retries up to 5 times with short delays to catch late-restored windows.
private func scheduleWelcomeWindowSuppression() {
Task { @MainActor [weak self] in
for _ in 0 ..< 5 {
guard let self else { return }
self.closeWelcomeWindowIfMainExists()
try? await Task.sleep(for: .milliseconds(200))
}
// Single check after a short delay for window creation
try? await Task.sleep(for: .milliseconds(300))
self?.closeWelcomeWindowIfMainExists()
// One final check after windows settle
try? await Task.sleep(for: .milliseconds(700))
guard let self else { return }
self.closeWelcomeWindowIfMainExists()
self.fileOpenSuppressionCount = max(0, self.fileOpenSuppressionCount - 1)
if self.fileOpenSuppressionCount == 0 {
self.isHandlingFileOpen = false
Expand All @@ -538,14 +536,9 @@ class AppDelegate: NSObject, NSApplicationDelegate {

private func postSQLFilesWhenReady(urls: [URL]) {
Task { @MainActor [weak self] in
for attempt in 0 ..< 10 {
if NSApp.windows.contains(where: { self?.isMainWindow($0) == true && $0.isKeyWindow }) {
break
}
if attempt == 9 {
Self.logger.warning("postSQLFilesWhenReady: no key main window after retries, posting anyway")
}
try? await Task.sleep(for: .milliseconds(50))
try? await Task.sleep(for: .milliseconds(100))
if !NSApp.windows.contains(where: { self?.isMainWindow($0) == true && $0.isKeyWindow }) {
Self.logger.warning("postSQLFilesWhenReady: no key main window, posting anyway")
}
NotificationCenter.default.post(name: .openSQLFiles, object: urls)
}
Expand Down
2 changes: 1 addition & 1 deletion TablePro/Core/Autocomplete/SQLCompletionProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -703,7 +703,7 @@ final class SQLCompletionProvider {
}

// Shorter names slightly preferred
score += item.label.count
score += (item.label as NSString).length

// Fuzzy match penalty — items matched only by fuzzy get demoted
if !prefix.isEmpty {
Expand Down
6 changes: 3 additions & 3 deletions TablePro/Core/Autocomplete/SQLSchemaProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -187,13 +187,13 @@ actor SQLSchemaProvider {
isPK: Bool, isNullable: Bool, defaultValue: String?, comment: String?
)] = []

let hasMultipleRefs = references.count > 1
for ref in references {
let columns = await getColumns(for: ref.tableName)
let refId = ref.identifier
for column in columns {
// Include table/alias prefix for clarity when multiple tables
let label = references.count > 1 ? "\(refId).\(column.name)" : column.name
let insertText = references.count > 1 ? "\(refId).\(column.name)" : column.name
let label = hasMultipleRefs ? "\(refId).\(column.name)" : column.name
let insertText = hasMultipleRefs ? "\(refId).\(column.name)" : column.name

itemDataBuilder.append(
(
Expand Down
4 changes: 4 additions & 0 deletions TablePro/Core/Database/ConnectionHealthMonitor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,10 @@ actor ConnectionHealthMonitor {
monitoringTask = Task { [weak self] in
guard let self else { return }

let initialDelay = Double.random(in: 0 ... 10)
try? await Task.sleep(for: .seconds(initialDelay))
guard !Task.isCancelled else { return }

while !Task.isCancelled {
// Race between the normal ping interval and an early wake-up signal
await withTaskGroup(of: Bool.self) { group in
Expand Down
15 changes: 6 additions & 9 deletions TablePro/Core/Database/GeometryWKBParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -120,9 +120,8 @@ enum GeometryWKBParser {
for _ in 0 ..< numGeoms {
guard let geom = parseWKBGeometry(data, offset: &offset) else { return nil }
if geom.hasPrefix("POINT("), geom.hasSuffix(")") {
let start = geom.index(geom.startIndex, offsetBy: 6)
let end = geom.index(before: geom.endIndex)
points.append(String(geom[start ..< end]))
let ns = geom as NSString
points.append(ns.substring(with: NSRange(location: 6, length: ns.length - 7)))
} else {
points.append(geom)
}
Expand All @@ -142,9 +141,8 @@ enum GeometryWKBParser {
for _ in 0 ..< numGeoms {
guard let geom = parseWKBGeometry(data, offset: &offset) else { return nil }
if geom.hasPrefix("LINESTRING("), geom.hasSuffix(")") {
let start = geom.index(geom.startIndex, offsetBy: 11)
let end = geom.index(before: geom.endIndex)
lineStrings.append("(\(geom[start ..< end]))")
let ns = geom as NSString
lineStrings.append("(\(ns.substring(with: NSRange(location: 11, length: ns.length - 12))))")
} else {
lineStrings.append(geom)
}
Expand All @@ -164,9 +162,8 @@ enum GeometryWKBParser {
for _ in 0 ..< numGeoms {
guard let geom = parseWKBGeometry(data, offset: &offset) else { return nil }
if geom.hasPrefix("POLYGON("), geom.hasSuffix(")") {
let start = geom.index(geom.startIndex, offsetBy: 8)
let end = geom.index(before: geom.endIndex)
polygons.append("(\(geom[start ..< end]))")
let ns = geom as NSString
polygons.append("(\(ns.substring(with: NSRange(location: 8, length: ns.length - 9))))")
} else {
polygons.append(geom)
}
Expand Down
2 changes: 1 addition & 1 deletion TablePro/Core/Database/RedisDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -435,7 +435,7 @@ final class RedisDriver: DatabaseDriver {
for line in infoStr.components(separatedBy: .newlines) {
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.hasPrefix("\(dbName):") {
let statsStr = String(trimmed[trimmed.index(trimmed.startIndex, offsetBy: dbName.count + 1)...])
let statsStr = (trimmed as NSString).substring(from: dbName.count + 1)
for stat in statsStr.components(separatedBy: ",") {
let parts = stat.components(separatedBy: "=")
if parts.count == 2, parts[0] == "keys", let count = Int(parts[1]) {
Expand Down
4 changes: 2 additions & 2 deletions TablePro/Core/Storage/QueryHistoryStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,8 @@ final class QueryHistoryStorage {
private var insertsSinceCleanup: Int = 0

private init() {
queue.sync {
setupDatabase()
queue.async { [weak self] in
self?.setupDatabase()
}
}

Expand Down
4 changes: 3 additions & 1 deletion TablePro/Core/Storage/TabStateStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,9 @@ final class TabStateStorage {
decoder = JSONDecoder()

createDirectoriesIfNeeded()
migrateFromUserDefaultsIfNeeded()
DispatchQueue.global(qos: .utility).async { [weak self] in
self?.migrateFromUserDefaultsIfNeeded()
}
}

// MARK: - Public API
Expand Down
14 changes: 4 additions & 10 deletions TablePro/Models/RowProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -185,11 +185,8 @@ final class InMemoryRowProvider: RowProvider {
/// Keeps the half closest to `nearIndex` and discards the rest.
private func evictCacheIfNeeded(nearIndex: Int) {
guard rowCache.count > Self.maxCacheSize / 2 else { return }
let sorted = rowCache.keys.sorted(by: { abs($0 - nearIndex) > abs($1 - nearIndex) })
let evictCount = rowCache.count - Self.maxCacheSize / 2
for key in sorted.prefix(evictCount) {
rowCache.removeValue(forKey: key)
}
let halfSize = Self.maxCacheSize / 2
rowCache = rowCache.filter { abs($0.key - nearIndex) <= halfSize }
}
}

Expand Down Expand Up @@ -301,10 +298,7 @@ final class DatabaseRowProvider: RowProvider {
/// and discards the rest.
private func evictCacheIfNeeded(nearIndex: Int) {
guard cache.count > Self.maxCacheSize else { return }
let sorted = cache.keys.sorted(by: { abs($0 - nearIndex) > abs($1 - nearIndex) })
let evictCount = cache.count - Self.maxCacheSize / 2
for key in sorted.prefix(evictCount) {
cache.removeValue(forKey: key)
}
let halfSize = Self.maxCacheSize / 2
cache = cache.filter { abs($0.key - nearIndex) <= halfSize }
}
}
14 changes: 14 additions & 0 deletions TableProTests/Core/Autocomplete/SQLCompletionProviderTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -949,4 +949,18 @@ struct SQLCompletionProviderTests {
#expect(containsIdx < fuzzyIdx, "Contains matches should rank above fuzzy-only matches")
}
}

// MARK: - Performance: NSString.length for label scoring

@Test("Shorter label scores lower (better) than longer label")
func testShorterLabelScoresBetter() async {
// "IN" (2 chars) should rank above "INSERT" (6 chars) when both match prefix "IN"
let text = "SELECT * FROM users WHERE id IN"
let (items, _) = await provider.getCompletions(text: text, cursorPosition: text.count)
let inIdx = items.firstIndex { $0.label == "IN" }
let insertIdx = items.firstIndex { $0.label == "INSERT" }
if let inIdx, let insertIdx {
#expect(inIdx < insertIdx, "IN should rank above INSERT for prefix 'IN'")
}
}
}
46 changes: 46 additions & 0 deletions TableProTests/Core/Autocomplete/SQLSchemaProviderTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -283,4 +283,50 @@ struct SQLSchemaProviderTests {

#expect(driver.fetchColumnsCallCount == 1)
}

@Test("allColumnsInScope with single reference returns unprefixed names")
func allColumnsInScopeSingleRef() async {
let driver = MockDatabaseDriver()
driver.tablesToReturn = [TestFixtures.makeTableInfo(name: "users")]
driver.columnsToReturn = [
"users": [
TestFixtures.makeColumnInfo(name: "id"),
TestFixtures.makeColumnInfo(name: "email", dataType: "VARCHAR", isPrimaryKey: false)
]
]

let provider = SQLSchemaProvider()
await provider.loadSchema(using: driver, connection: TestFixtures.makeConnection())

let ref = TableReference(tableName: "users", alias: nil)
let items = await provider.allColumnsInScope(for: [ref])
#expect(items.count == 2)
#expect(items[0].label == "id")
#expect(items[1].label == "email")
}

@Test("allColumnsInScope with multiple references returns prefixed names")
func allColumnsInScopeMultipleRefs() async {
let driver = MockDatabaseDriver()
driver.tablesToReturn = [
TestFixtures.makeTableInfo(name: "users"),
TestFixtures.makeTableInfo(name: "orders")
]
driver.columnsToReturn = [
"users": [TestFixtures.makeColumnInfo(name: "id")],
"orders": [TestFixtures.makeColumnInfo(name: "id")]
]

let provider = SQLSchemaProvider()
await provider.loadSchema(using: driver, connection: TestFixtures.makeConnection())

let refs = [
TableReference(tableName: "users", alias: nil),
TableReference(tableName: "orders", alias: nil)
]
let items = await provider.allColumnsInScope(for: refs)
#expect(items.count == 2)
#expect(items[0].label == "users.id")
#expect(items[1].label == "orders.id")
}
}
31 changes: 31 additions & 0 deletions TableProTests/Core/Database/ConnectionHealthMonitorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -120,4 +120,35 @@ struct ConnectionHealthMonitorTests {
let name = Notification.Name.connectionHealthStateChanged
#expect(name.rawValue == "connectionHealthStateChanged")
}

@Test("Staggered initial delay — no ping fires immediately")
func staggeredInitialDelay() async {
var pingCount = 0
let lock = NSLock()

let monitor = ConnectionHealthMonitor(
connectionId: UUID(),
pingHandler: {
lock.lock()
pingCount += 1
lock.unlock()
return true
},
reconnectHandler: { true },
onStateChanged: { _, _ in }
)

await monitor.startMonitoring()

// Wait briefly — with stagger (0-10s) + ping interval (30s),
// no ping should fire in 200ms
try? await Task.sleep(for: .milliseconds(200))

await monitor.stopMonitoring()

lock.lock()
let count = pingCount
lock.unlock()
#expect(count == 0, "No ping should fire immediately due to staggered initial delay")
}
}
29 changes: 29 additions & 0 deletions TableProTests/Models/RowProviderTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -511,4 +511,33 @@ struct InMemoryRowProviderTests {
#expect(row != nil)
#expect(row?.value(at: 0) == "id_0")
}

@Test("Eviction keeps rows closest to access point")
func evictionKeepsClosestRows() {
let provider = TestFixtures.makeInMemoryRowProvider(rowCount: 6000)
for i in 0 ..< 6000 {
let _ = provider.row(at: i)
}
let _ = provider.row(at: 4000)
let nearby = provider.row(at: 4001)
#expect(nearby != nil)
#expect(nearby?.value(at: 0) == "id_4001")
}

@Test("Eviction preserves data integrity across multiple eviction cycles")
func evictionMultipleCycles() {
let provider = TestFixtures.makeInMemoryRowProvider(rowCount: 12000)
for i in 0 ..< 6000 {
let _ = provider.row(at: i)
}
for i in 6000 ..< 12000 {
let _ = provider.row(at: i)
}
let early = provider.row(at: 100)
#expect(early != nil)
#expect(early?.value(at: 0) == "id_100")
let late = provider.row(at: 11999)
#expect(late != nil)
#expect(late?.value(at: 0) == "id_11999")
}
}