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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed

- Moving a connection into or out of a group now syncs across devices, instead of leaving it ungrouped on your other Macs.
- Opening a table on a connection with many tables no longer stalls for several seconds while autocomplete and table metadata load. Background schema introspection now runs on separate connections instead of waiting behind, or blocking, the query that fills the grid. (#1483)

## [0.46.0] - 2026-05-28

Expand Down
42 changes: 31 additions & 11 deletions TablePro/Core/Autocomplete/SQLSchemaProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,19 @@ actor SQLSchemaProvider {
private var loadTask: Task<Void, Never>?
private var eagerColumnTask: Task<Void, Never>?

// Store a weak driver reference to avoid retaining it after disconnect (MEM-9)
private weak var cachedDriver: (any DatabaseDriver)?
struct ColumnMetadataSource: Sendable {
let fetchColumns: @Sendable (_ table: String) async throws -> [ColumnInfo]
let fetchAllColumns: @Sendable () async throws -> [String: [ColumnInfo]]
}

// Store connection info for reference
private weak var cachedDriver: (any DatabaseDriver)?
private let metadataSource: ColumnMetadataSource?
private var connectionInfo: DatabaseConnection?

init(metadataSource: ColumnMetadataSource? = nil) {
self.metadataSource = metadataSource
}

// MARK: - Public API

/// Load schema from the database (driver should already be connected).
Expand Down Expand Up @@ -97,12 +104,15 @@ actor SQLSchemaProvider {
return cached
}

guard let driver = cachedDriver else {
return []
}

do {
let columns = try await driver.fetchColumns(table: tableName)
let columns: [ColumnInfo]
if let metadataSource {
columns = try await metadataSource.fetchColumns(tableName)
} else if let driver = cachedDriver {
columns = try await driver.fetchColumns(table: tableName)
} else {
return []
}
columnCache[key] = columns
columnAccessOrder.append(key)
evictIfNeeded()
Expand Down Expand Up @@ -168,13 +178,23 @@ actor SQLSchemaProvider {
// MARK: - Eager Column Loading

private func startEagerColumnLoad() {
guard !tables.isEmpty, let driver = cachedDriver else { return }
guard !tables.isEmpty else { return }
let source = metadataSource
let driver = cachedDriver
guard source != nil || driver != nil else { return }
eagerColumnTask?.cancel()
let tableCount = tables.count
eagerColumnTask = Task {
eagerColumnTask = Task(priority: .utility) {
Self.logger.info("[schema] eager column load starting tableCount=\(tableCount)")
do {
let allColumns = try await driver.fetchAllColumns()
let allColumns: [String: [ColumnInfo]]
if let source {
allColumns = try await source.fetchAllColumns()
} else if let driver {
allColumns = try await driver.fetchAllColumns()
} else {
return
}
guard !Task.isCancelled else { return }
self.populateColumnCache(allColumns)
Self.logger.info("[schema] eager column load complete cachedCount=\(self.columnCache.count)")
Expand Down
34 changes: 19 additions & 15 deletions TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -261,26 +261,22 @@ extension QueryExecutionCoordinator {

let isNonSQL = PluginManager.shared.editorLanguage(for: connectionType) != .sql
guard !isNonSQL else { return }
guard let enumDriver = DatabaseManager.shared.driver(for: parent.connectionId) else { return }
Task(priority: .background) { [weak self, parent] in
Task(priority: .utility) { [weak self, parent] in
guard let self else { return }
guard !parent.isTearingDown else { return }

let columnInfo: [ColumnInfo]
if let schema = schemaResult {
columnInfo = schema.columnInfo
} else {
do {
columnInfo = try await enumDriver.fetchColumns(table: tableName)
} catch {
columnInfo = []
}
columnInfo = (try? await DatabaseManager.shared.withMetadataDriver(connectionId: parent.connectionId) { driver in
try await driver.fetchColumns(table: tableName)
}) ?? []
}

let columnEnumValues = await parent.fetchEnumValues(
columnInfo: columnInfo,
tableName: tableName,
driver: enumDriver,
connectionType: connectionType
)

Expand Down Expand Up @@ -336,10 +332,9 @@ extension QueryExecutionCoordinator {
) {
let isNonSQL = PluginManager.shared.editorLanguage(for: connectionType) != .sql

Task(priority: .background) { [weak self, parent] in
Task(priority: .utility) { [weak self, parent] in
guard let self else { return }
guard !parent.isTearingDown else { return }
guard let driver = DatabaseManager.shared.driver(for: parent.connectionId) else { return }

let prepared: (plan: RowCountPlan, sql: String?) = await MainActor.run {
guard let tab = parent.tabManager.tabs.first(where: { $0.id == tabId }) else { return (.skip, nil) }
Expand All @@ -366,24 +361,33 @@ extension QueryExecutionCoordinator {
case .clear:
outcome = .clear
case .approximate:
guard let count = try? await driver.fetchApproximateRowCount(table: tableName) else { return }
guard let count = try? await DatabaseManager.shared.withMetadataDriver(connectionId: parent.connectionId, { driver in
try await driver.fetchApproximateRowCount(table: tableName)
}) else { return }
outcome = .count(count, isApproximate: true)
case let .filteredNonSQL(filters, logicMode):
if let count = try? await driver.fetchFilteredRowCount(table: tableName, filters: filters, logicMode: logicMode) {
if let count = try? await DatabaseManager.shared.withMetadataDriver(connectionId: parent.connectionId, workload: .bulk, { driver in
try await driver.fetchFilteredRowCount(table: tableName, filters: filters, logicMode: logicMode)
}) {
outcome = .count(count, isApproximate: false)
} else {
outcome = .clear
}
case .exactCount:
guard let sql = prepared.sql else { return }
let count: Int?
do {
let result = try await driver.execute(query: sql)
guard let countStr = result.rows.first?.first?.asText, let count = Int(countStr) else { return }
outcome = .count(count, isApproximate: false)
count = try await DatabaseManager.shared.withMetadataDriver(connectionId: parent.connectionId, workload: .bulk) { driver in
let result = try await driver.execute(query: sql)
Comment on lines +380 to +381
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep row counts on the session connection

When an editable table is connection-local (for example a PostgreSQL/MySQL temporary table, or a temp table shadowing a permanent table), this new count path runs SELECT COUNT(*) on a pooled metadata connection instead of the session connection that loaded the grid. That separate connection cannot see the same temp objects/session state, so the row-count refresh can fail or report a different table’s count even though the data grid query itself succeeded; the prior implementation executed this count on the active driver.

Useful? React with 👍 / 👎.

guard let countStr = result.rows.first?.first?.asText else { return Int?.none }
return Int(countStr)
}
} catch {
helpersLogger.warning("COUNT query failed for \(tableName): \(error.localizedDescription)")
return
}
guard let count else { return }
outcome = .count(count, isApproximate: false)
}

await MainActor.run {
Expand Down
25 changes: 25 additions & 0 deletions TablePro/Core/Database/DatabaseManager+Metadata.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
//
// DatabaseManager+Metadata.swift
// TablePro
//

import Foundation

extension DatabaseManager {
func withMetadataDriver<T: Sendable>(
connectionId: UUID,
workload: MetadataConnectionPool.Workload = .interactive,
_ body: @Sendable @escaping (DatabaseDriver) async throws -> T
) async throws -> T {
guard let session = session(for: connectionId) else {
throw DatabaseError.notConnected
}
return try await MetadataConnectionPool.shared.withDriver(
connectionId: connectionId,
database: session.activeDatabase,
Comment thread
datlechin marked this conversation as resolved.
schema: session.currentSchema,
workload: workload,
body
)
}
}
31 changes: 27 additions & 4 deletions TablePro/Core/Services/Query/MetadataConnectionPool.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,16 @@ import Foundation
final class MetadataConnectionPool {
static let shared = MetadataConnectionPool()

enum Workload: Hashable, Sendable {
case interactive
case bulk
}

private struct Key: Hashable, Sendable {
let connectionId: UUID
let database: String
let schema: String?
let workload: Workload
}

@MainActor
Expand Down Expand Up @@ -45,17 +52,21 @@ final class MetadataConnectionPool {

private var entries: [Key: Entry] = [:]
private var pending: [Key: Task<Void, Error>] = [:]
private let maxPerConnection = 4
private let maxPerConnection = 6
private let connectTimeoutSeconds: UInt64 = 15

private init() {}

func withDriver<T: Sendable>(
connectionId: UUID,
database: String,
schema: String? = nil,
workload: Workload = .interactive,
_ body: @Sendable @escaping (DatabaseDriver) async throws -> T
) async throws -> T {
let entry = try await acquireEntry(connectionId: connectionId, database: database)
let entry = try await acquireEntry(
connectionId: connectionId, database: database, schema: schema, workload: workload
)
entry.inFlightCount += 1
entry.lastUsed = Date()
defer { releaseEntry(entry) }
Expand Down Expand Up @@ -88,8 +99,13 @@ final class MetadataConnectionPool {
}
}

private func acquireEntry(connectionId: UUID, database: String) async throws -> Entry {
let key = Key(connectionId: connectionId, database: database)
private func acquireEntry(
connectionId: UUID,
database: String,
schema: String?,
workload: Workload
) async throws -> Entry {
let key = Key(connectionId: connectionId, database: database, schema: schema, workload: workload)
if let entry = entries[key], entry.driver.status == .connected {
return entry
}
Expand Down Expand Up @@ -136,6 +152,13 @@ final class MetadataConnectionPool {
)
do {
try await connectWithTimeout(driver: driver, database: key.database)
try? await driver.applyQueryTimeout(AppSettingsManager.shared.general.queryTimeoutSeconds)
await DatabaseManager.shared.executeStartupCommands(
Comment thread
datlechin marked this conversation as resolved.
session.connection.startupCommands, on: driver, connectionName: session.connection.name
)
if let schema = key.schema, let switchable = driver as? SchemaSwitchable {
try await switchable.switchSchema(to: schema)
}
} catch {
driver.disconnect()
throw error
Expand Down
29 changes: 11 additions & 18 deletions TablePro/Core/Services/Query/QueryExecutor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -63,18 +63,7 @@ final class QueryExecutor {
var parallelSchemaTask: Task<SchemaResult, Error>?
if fetchSchemaForTable, let tableName, !tableName.isEmpty {
parallelSchemaTask = Task {
guard let driver = DatabaseManager.shared.driver(for: connId) else {
throw DatabaseError.notConnected
}
async let cols = driver.fetchColumns(table: tableName)
async let fks = driver.fetchForeignKeys(table: tableName)
let result = try await (columnInfo: cols, fkInfo: fks)
let approxCount = try? await driver.fetchApproximateRowCount(table: tableName)
return (
columnInfo: result.columnInfo,
fkInfo: result.fkInfo,
approximateRowCount: approxCount
)
try await Self.fetchTableSchema(connectionId: connId, tableName: tableName)
}
}

Expand Down Expand Up @@ -174,19 +163,23 @@ final class QueryExecutor {
if let parallelTask {
return try? await parallelTask.value
}
guard let driver = DatabaseManager.shared.driver(for: connectionId) else { return nil }
do {
async let cols = driver.fetchColumns(table: tableName)
async let fks = driver.fetchForeignKeys(table: tableName)
let (c, f) = try await (cols, fks)
let approxCount = try? await driver.fetchApproximateRowCount(table: tableName)
return (columnInfo: c, fkInfo: f, approximateRowCount: approxCount)
return try await fetchTableSchema(connectionId: connectionId, tableName: tableName)
} catch {
queryExecutorLog.error("Phase 2 schema fetch failed: \(error.localizedDescription, privacy: .public)")
return nil
}
}

static func fetchTableSchema(connectionId: UUID, tableName: String) async throws -> SchemaResult {
try await DatabaseManager.shared.withMetadataDriver(connectionId: connectionId) { driver in
let columns = try await driver.fetchColumns(table: tableName)
let foreignKeys = try await driver.fetchForeignKeys(table: tableName)
let approximateRowCount = try? await driver.fetchApproximateRowCount(table: tableName)
return (columnInfo: columns, fkInfo: foreignKeys, approximateRowCount: approximateRowCount)
}
}

static func parseSchemaMetadata(_ schema: SchemaResult) -> ParsedSchemaMetadata {
var defaults: [String: String?] = [:]
var fks: [String: ForeignKeyInfo] = [:]
Expand Down
14 changes: 13 additions & 1 deletion TablePro/Core/Services/Query/SchemaProviderRegistry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,19 @@ final class SchemaProviderRegistry {
if let existing = providers[connectionId] {
return existing
}
let provider = SQLSchemaProvider()
let source = SQLSchemaProvider.ColumnMetadataSource(
fetchColumns: { table in
try await DatabaseManager.shared.withMetadataDriver(connectionId: connectionId) { driver in
try await driver.fetchColumns(table: table)
}
},
fetchAllColumns: {
try await DatabaseManager.shared.withMetadataDriver(connectionId: connectionId, workload: .bulk) { driver in
try await driver.fetchAllColumns()
}
}
)
let provider = SQLSchemaProvider(metadataSource: source)
providers[connectionId] = provider
return provider
}
Expand Down
24 changes: 16 additions & 8 deletions TablePro/ViewModels/AIChatViewModel+SchemaContext.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,19 +29,23 @@ extension AIChatViewModel {
await inFlight.value
return
}
guard let connection,
let driver = services.databaseManager.driver(for: connection.id) else { return }
guard let connection else { return }
let connId = connection.id
let task: Task<Void, Never> = Task { [weak self] in
let columns: [ColumnInfo]
do {
columns = try await driver.fetchColumns(table: tableName)
columns = try await DatabaseManager.shared.withMetadataDriver(connectionId: connId) { driver in
try await driver.fetchColumns(table: tableName)
}
} catch {
Self.logger.warning("Column fetch failed for \(tableName, privacy: .public): \(error.localizedDescription, privacy: .public)")
columns = []
}
let fkMap: [String: [ForeignKeyInfo]]
do {
fkMap = try await driver.fetchForeignKeys(forTables: [tableName])
fkMap = try await DatabaseManager.shared.withMetadataDriver(connectionId: connId) { driver in
try await driver.fetchForeignKeys(forTables: [tableName])
}
} catch {
Self.logger.warning("Foreign key fetch failed for \(tableName, privacy: .public): \(error.localizedDescription, privacy: .public)")
fkMap = [:]
Expand Down Expand Up @@ -92,8 +96,8 @@ extension AIChatViewModel {
}

private func runSchemaLoad() async {
guard let connection,
let driver = services.databaseManager.driver(for: connection.id) else { return }
guard let connection else { return }
let connId = connection.id
let settings = services.appSettings.ai
let tablesToFetch = Array(tables.prefix(settings.maxSchemaTables))
guard !tablesToFetch.isEmpty else { return }
Expand All @@ -103,7 +107,9 @@ extension AIChatViewModel {
let name = table.name
group.addTask {
do {
let cols = try await driver.fetchColumns(table: name)
let cols = try await DatabaseManager.shared.withMetadataDriver(connectionId: connId, workload: .bulk) { driver in
try await driver.fetchColumns(table: name)
}
return (name, cols)
} catch {
Self.logger.warning("Schema column fetch failed for \(name, privacy: .public): \(error.localizedDescription, privacy: .public)")
Expand All @@ -121,7 +127,9 @@ extension AIChatViewModel {
let needsFKFetch = tablesToFetch.contains { foreignKeysByTable[$0.name] == nil }
guard needsFKFetch else { return }
do {
let fkMap = try await driver.fetchForeignKeys(forTables: tablesToFetch.map(\.name))
let fkMap = try await DatabaseManager.shared.withMetadataDriver(connectionId: connId, workload: .bulk) { driver in
try await driver.fetchForeignKeys(forTables: tablesToFetch.map(\.name))
}
for (name, fks) in fkMap {
foreignKeysByTable[name] = fks
}
Expand Down
Loading
Loading