diff --git a/CHANGELOG.md b/CHANGELOG.md index 96763b413..33ead989d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/TablePro/Core/Autocomplete/SQLSchemaProvider.swift b/TablePro/Core/Autocomplete/SQLSchemaProvider.swift index 9bb028bf6..b17bafb84 100644 --- a/TablePro/Core/Autocomplete/SQLSchemaProvider.swift +++ b/TablePro/Core/Autocomplete/SQLSchemaProvider.swift @@ -25,12 +25,19 @@ actor SQLSchemaProvider { private var loadTask: Task? private var eagerColumnTask: Task? - // 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). @@ -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() @@ -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)") diff --git a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift index cb510ec27..e84d46022 100644 --- a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift +++ b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift @@ -261,8 +261,7 @@ 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 } @@ -270,17 +269,14 @@ extension QueryExecutionCoordinator { 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 ) @@ -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) } @@ -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) + 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 { diff --git a/TablePro/Core/Database/DatabaseManager+Metadata.swift b/TablePro/Core/Database/DatabaseManager+Metadata.swift new file mode 100644 index 000000000..31ae6b95f --- /dev/null +++ b/TablePro/Core/Database/DatabaseManager+Metadata.swift @@ -0,0 +1,25 @@ +// +// DatabaseManager+Metadata.swift +// TablePro +// + +import Foundation + +extension DatabaseManager { + func withMetadataDriver( + 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, + schema: session.currentSchema, + workload: workload, + body + ) + } +} diff --git a/TablePro/Core/Services/Query/MetadataConnectionPool.swift b/TablePro/Core/Services/Query/MetadataConnectionPool.swift index 905fb20cd..8acb31bbb 100644 --- a/TablePro/Core/Services/Query/MetadataConnectionPool.swift +++ b/TablePro/Core/Services/Query/MetadataConnectionPool.swift @@ -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 @@ -45,7 +52,7 @@ final class MetadataConnectionPool { private var entries: [Key: Entry] = [:] private var pending: [Key: Task] = [:] - private let maxPerConnection = 4 + private let maxPerConnection = 6 private let connectTimeoutSeconds: UInt64 = 15 private init() {} @@ -53,9 +60,13 @@ final class MetadataConnectionPool { func withDriver( 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) } @@ -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 } @@ -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( + 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 diff --git a/TablePro/Core/Services/Query/QueryExecutor.swift b/TablePro/Core/Services/Query/QueryExecutor.swift index b0af3e761..27c1086d2 100644 --- a/TablePro/Core/Services/Query/QueryExecutor.swift +++ b/TablePro/Core/Services/Query/QueryExecutor.swift @@ -63,18 +63,7 @@ final class QueryExecutor { var parallelSchemaTask: Task? 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) } } @@ -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] = [:] diff --git a/TablePro/Core/Services/Query/SchemaProviderRegistry.swift b/TablePro/Core/Services/Query/SchemaProviderRegistry.swift index a44136b16..bcc5ab13b 100644 --- a/TablePro/Core/Services/Query/SchemaProviderRegistry.swift +++ b/TablePro/Core/Services/Query/SchemaProviderRegistry.swift @@ -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 } diff --git a/TablePro/ViewModels/AIChatViewModel+SchemaContext.swift b/TablePro/ViewModels/AIChatViewModel+SchemaContext.swift index e3883d73d..8906ad01b 100644 --- a/TablePro/ViewModels/AIChatViewModel+SchemaContext.swift +++ b/TablePro/ViewModels/AIChatViewModel+SchemaContext.swift @@ -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 = 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 = [:] @@ -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 } @@ -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)") @@ -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 } diff --git a/TablePro/ViewModels/DatabaseSwitcherViewModel.swift b/TablePro/ViewModels/DatabaseSwitcherViewModel.swift index 638b1b381..9849990bf 100644 --- a/TablePro/ViewModels/DatabaseSwitcherViewModel.swift +++ b/TablePro/ViewModels/DatabaseSwitcherViewModel.swift @@ -52,13 +52,9 @@ final class DatabaseSwitcherViewModel { errorMessage = nil do { - guard let driver = services.databaseManager.driver(for: connectionId) else { - errorMessage = String(localized: "No active connection") - isLoading = false - return + let dbNames = try await services.databaseManager.withMetadataDriver(connectionId: connectionId) { driver in + try await driver.fetchDatabases() } - - let dbNames = try await driver.fetchDatabases() databases = dbNames.sorted().map { name in DatabaseMetadata.minimal(name: name, isSystem: isSystemItem(name)) } @@ -67,7 +63,9 @@ final class DatabaseSwitcherViewModel { isLoading = false do { - let metadataList = try await driver.fetchAllDatabaseMetadata() + let metadataList = try await services.databaseManager.withMetadataDriver(connectionId: connectionId, workload: .bulk) { driver in + try await driver.fetchAllDatabaseMetadata() + } databases = metadataList.sorted { $0.name < $1.name } preselectDatabase() } catch { diff --git a/TablePro/ViewModels/ERDiagramViewModel.swift b/TablePro/ViewModels/ERDiagramViewModel.swift index c978fe8ac..db3d86440 100644 --- a/TablePro/ViewModels/ERDiagramViewModel.swift +++ b/TablePro/ViewModels/ERDiagramViewModel.swift @@ -95,15 +95,19 @@ final class ERDiagramViewModel { await waitForConnection() } - guard let driver = services.databaseManager.driver(for: connectionId) else { + guard services.databaseManager.driver(for: connectionId) != nil else { loadState = .failed(String(localized: "No database connection")) return } do { - async let columnsResult = driver.fetchAllColumns() - async let fksResult = driver.fetchAllForeignKeys() - let (allColumns, allFKs) = try await (columnsResult, fksResult) + let (allColumns, allFKs) = try await services.databaseManager.withMetadataDriver( + connectionId: connectionId, workload: .bulk + ) { driver in + let cols = try await driver.fetchAllColumns() + let fks = try await driver.fetchAllForeignKeys() + return (cols, fks) + } let builtGraph = ERDiagramGraphBuilder.build( allColumns: allColumns, diff --git a/TablePro/ViewModels/QuickSwitcherViewModel.swift b/TablePro/ViewModels/QuickSwitcherViewModel.swift index 4e84b93c4..da6c16107 100644 --- a/TablePro/ViewModels/QuickSwitcherViewModel.swift +++ b/TablePro/ViewModels/QuickSwitcherViewModel.swift @@ -104,35 +104,37 @@ internal final class QuickSwitcherViewModel { )) } - if let driver = services.databaseManager.driver(for: connectionId) { + do { + let databases = try await services.databaseManager.withMetadataDriver(connectionId: connectionId) { driver in + try await driver.fetchDatabases() + } + for db in databases { + items.append(QuickSwitcherItem( + id: "db_\(db)", + name: db, + kind: .database, + subtitle: String(localized: "Database") + )) + } + } catch { + Self.logger.warning("Failed to fetch databases: \(error.localizedDescription, privacy: .public)") + } + + if services.pluginManager.supportsSchemaSwitching(for: databaseType) { do { - let databases = try await driver.fetchDatabases() - for db in databases { + let schemas = try await services.databaseManager.withMetadataDriver(connectionId: connectionId) { driver in + try await driver.fetchSchemas() + } + for schema in schemas { items.append(QuickSwitcherItem( - id: "db_\(db)", - name: db, - kind: .database, - subtitle: String(localized: "Database") + id: "schema_\(schema)", + name: schema, + kind: .schema, + subtitle: String(localized: "Schema") )) } } catch { - Self.logger.warning("Failed to fetch databases: \(error.localizedDescription, privacy: .public)") - } - - if services.pluginManager.supportsSchemaSwitching(for: databaseType) { - do { - let schemas = try await driver.fetchSchemas() - for schema in schemas { - items.append(QuickSwitcherItem( - id: "schema_\(schema)", - name: schema, - kind: .schema, - subtitle: String(localized: "Schema") - )) - } - } catch { - Self.logger.warning("Failed to fetch schemas: \(error.localizedDescription, privacy: .public)") - } + Self.logger.warning("Failed to fetch schemas: \(error.localizedDescription, privacy: .public)") } } diff --git a/TablePro/Views/Export/ExportDialog.swift b/TablePro/Views/Export/ExportDialog.swift index 5937681f9..d826ec29f 100644 --- a/TablePro/Views/Export/ExportDialog.swift +++ b/TablePro/Views/Export/ExportDialog.swift @@ -550,16 +550,6 @@ struct ExportDialog: View { @MainActor private func loadDatabaseItems() async { - guard let driver = DatabaseManager.shared.driver(for: connection.id) else { - isLoading = false - AlertHelper.showErrorSheet( - title: String(localized: "Export Error"), - message: String(localized: "Not connected to database"), - window: nil - ) - return - } - // Snapshot user-toggled selections before replacing items let priorSelections = currentSelectionState() @@ -570,10 +560,12 @@ struct ExportDialog: View { let grouping = PluginManager.shared.databaseGroupingStrategy(for: dbType) switch grouping { case .bySchema, .hierarchicalSchema: - let schemas = try await driver.fetchSchemas() + let schemas = try await DatabaseManager.shared.withMetadataDriver(connectionId: connection.id, workload: .bulk) { driver in + try await driver.fetchSchemas() + } let defaultSchema = PluginManager.shared.defaultSchemaName(for: dbType) for schema in schemas { - let tables = try await fetchTablesForSchema(schema, driver: driver) + let tables = try await fetchTablesForSchema(schema) let isDefaultSchema = schema.caseInsensitiveCompare(defaultSchema) == .orderedSame let tableItems = tables.map { table in let key = "\(schema).\(table.name)" @@ -602,15 +594,16 @@ struct ExportDialog: View { case .flat: let fallbackName = PluginManager.shared.defaultGroupName(for: dbType) let dbItem = try await buildFlatDatabaseItem( - driver: driver, name: connection.database.isEmpty ? fallbackName : connection.database, priorSelections: priorSelections ) if let dbItem { items.append(dbItem) } case .byDatabase: - let databases = try await driver.fetchDatabases() + let databases = try await DatabaseManager.shared.withMetadataDriver(connectionId: connection.id, workload: .bulk) { driver in + try await driver.fetchDatabases() + } for dbName in databases { - let tables = try await fetchTablesForDatabase(dbName, driver: driver) + let tables = try await fetchTablesForDatabase(dbName) let isCurrentDB = dbName == connection.database let tableItems = tables.map { table in let key = "\(dbName).\(table.name)" @@ -658,11 +651,12 @@ struct ExportDialog: View { } private func buildFlatDatabaseItem( - driver: DatabaseDriver, name: String, priorSelections: [String: Bool] = [:] ) async throws -> ExportDatabaseItem? { - let tables = try await driver.fetchTables() + let tables = try await DatabaseManager.shared.withMetadataDriver(connectionId: connection.id, workload: .bulk) { driver in + try await driver.fetchTables() + } let tableItems = tables.map { table in let key = "\(name).\(table.name)" let selected = priorSelections[key] ?? preselectedTables.contains(table.name) @@ -677,64 +671,66 @@ struct ExportDialog: View { return ExportDatabaseItem(name: name, tables: tableItems, isExpanded: true) } - private func fetchTablesForSchema(_ schema: String, driver: DatabaseDriver) async throws -> [TableInfo] { - // Oracle does not have information_schema — use ALL_TABLES/ALL_VIEWS - if connection.type.pluginTypeId == "Oracle" { - let escapedSchema = schema.replacingOccurrences(of: "'", with: "''") + private func fetchTablesForSchema(_ schema: String) async throws -> [TableInfo] { + let isOracle = connection.type.pluginTypeId == "Oracle" + return try await DatabaseManager.shared.withMetadataDriver(connectionId: connection.id, workload: .bulk) { driver in + if isOracle { + let escapedSchema = schema.replacingOccurrences(of: "'", with: "''") + let query = """ + SELECT TABLE_NAME, 'BASE TABLE' AS TABLE_TYPE FROM ALL_TABLES WHERE OWNER = '\(escapedSchema)' + UNION ALL + SELECT VIEW_NAME, 'VIEW' FROM ALL_VIEWS WHERE OWNER = '\(escapedSchema)' + ORDER BY 1 + """ + let result = try await driver.execute(query: query) + return result.rows.compactMap { row -> TableInfo? in + guard let name = row[safe: 0]?.asText else { return nil } + let typeStr = row[safe: 1]?.asText ?? "BASE TABLE" + let type: TableInfo.TableType = typeStr.uppercased().contains("VIEW") ? .view : .table + return TableInfo(name: name, type: type, rowCount: nil) + } + } + let query = """ - SELECT TABLE_NAME, 'BASE TABLE' AS TABLE_TYPE FROM ALL_TABLES WHERE OWNER = '\(escapedSchema)' - UNION ALL - SELECT VIEW_NAME, 'VIEW' FROM ALL_VIEWS WHERE OWNER = '\(escapedSchema)' - ORDER BY 1 + SELECT table_schema, table_name, table_type + FROM information_schema.tables + ORDER BY table_name """ let result = try await driver.execute(query: query) return result.rows.compactMap { row -> TableInfo? in - guard let name = row[safe: 0]?.asText else { return nil } - let typeStr = row[safe: 1]?.asText ?? "BASE TABLE" + guard row.count >= 2, + let rowSchema = row[0].asText, + rowSchema == schema, + let name = row[1].asText else { + return nil + } + let typeStr = row.count > 2 ? (row[2].asText ?? "BASE TABLE") : "BASE TABLE" let type: TableInfo.TableType = typeStr.uppercased().contains("VIEW") ? .view : .table return TableInfo(name: name, type: type, rowCount: nil) } } + } - let query = """ - SELECT table_schema, table_name, table_type - FROM information_schema.tables - ORDER BY table_name - """ - let result = try await driver.execute(query: query) - return result.rows.compactMap { row -> TableInfo? in - guard row.count >= 2, - let rowSchema = row[0].asText, - rowSchema == schema, - let name = row[1].asText else { - return nil - } - let typeStr = row.count > 2 ? (row[2].asText ?? "BASE TABLE") : "BASE TABLE" - let type: TableInfo.TableType = typeStr.uppercased().contains("VIEW") ? .view : .table - return TableInfo(name: name, type: type, rowCount: nil) - } - } - - private func fetchTablesForDatabase(_ database: String, driver: DatabaseDriver) async throws -> [TableInfo] { - // Fetch tables from information_schema and filter by database in Swift to avoid SQL interpolation. - // MySQL/MariaDB: information_schema.TABLES contains TABLE_SCHEMA, TABLE_NAME, and TABLE_TYPE. - let query = """ - SELECT TABLE_SCHEMA, TABLE_NAME, TABLE_TYPE - FROM information_schema.TABLES - ORDER BY TABLE_NAME - """ - let result = try await driver.execute(query: query) - - return result.rows.compactMap { row -> TableInfo? in - guard row.count >= 2, - let rowSchema = row[0].asText, - rowSchema == database, - let name = row[1].asText else { - return nil + private func fetchTablesForDatabase(_ database: String) async throws -> [TableInfo] { + try await DatabaseManager.shared.withMetadataDriver(connectionId: connection.id, workload: .bulk) { driver in + let query = """ + SELECT TABLE_SCHEMA, TABLE_NAME, TABLE_TYPE + FROM information_schema.TABLES + ORDER BY TABLE_NAME + """ + let result = try await driver.execute(query: query) + + return result.rows.compactMap { row -> TableInfo? in + guard row.count >= 2, + let rowSchema = row[0].asText, + rowSchema == database, + let name = row[1].asText else { + return nil + } + let typeStr = row.count > 2 ? (row[2].asText ?? "BASE TABLE") : "BASE TABLE" + let type: TableInfo.TableType = typeStr.uppercased().contains("VIEW") ? .view : .table + return TableInfo(name: name, type: type, rowCount: nil) } - let typeStr = row.count > 2 ? (row[2].asText ?? "BASE TABLE") : "BASE TABLE" - let type: TableInfo.TableType = typeStr.uppercased().contains("VIEW") ? .view : .table - return TableInfo(name: name, type: type, rowCount: nil) } } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnFetchScope.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnFetchScope.swift index c7b0b8b54..2c52cc719 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnFetchScope.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnFetchScope.swift @@ -43,12 +43,10 @@ extension MainContentCoordinator { func loadSchemaColumns(for tableName: String, schema: String?) async { let key = schemaColumnsKey(tableName, schema: schema) guard schemaColumnsCache[key] == nil else { return } - guard let driver = services.databaseManager.driver(for: connectionId) else { - columnScopeLog.error("loadSchemaColumns: no driver for connection; cannot scope columns for table=\(tableName, privacy: .public)") - return - } do { - let columns = try await driver.fetchColumns(table: tableName, schema: schema) + let columns = try await services.databaseManager.withMetadataDriver(connectionId: connectionId) { driver in + try await driver.fetchColumns(table: tableName, schema: schema) + } guard !columns.isEmpty else { columnScopeLog.error("loadSchemaColumns: 0 columns for table=\(tableName, privacy: .public); cannot scope") return diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift index 4c3818f6a..074390a7a 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift @@ -98,8 +98,9 @@ extension MainContentCoordinator { func editViewDefinition(_ viewName: String) { Task { do { - guard let driver = DatabaseManager.shared.driver(for: self.connection.id) else { return } - let definition = try await driver.fetchViewDefinition(view: viewName) + let definition = try await DatabaseManager.shared.withMetadataDriver(connectionId: self.connection.id) { driver in + try await driver.fetchViewDefinition(view: viewName) + } let payload = EditorTabPayload( connectionId: connection.id, diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 6317634dc..cbb27a833 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -739,10 +739,10 @@ final class MainContentCoordinator { } func loadTableMetadata(tableName: String) async { - guard let driver = services.databaseManager.driver(for: connectionId) else { return } - do { - let metadata = try await driver.fetchTableMetadata(tableName: tableName) + let metadata = try await services.databaseManager.withMetadataDriver(connectionId: connectionId) { driver in + try await driver.fetchTableMetadata(tableName: tableName) + } self.tableMetadata = metadata } catch { Self.logger.error("Failed to load table metadata: \(error.localizedDescription, privacy: .public)") @@ -1161,7 +1161,6 @@ final class MainContentCoordinator { func fetchEnumValues( columnInfo: [ColumnInfo], tableName: String, - driver: DatabaseDriver, connectionType: DatabaseType ) async -> [String: [String]] { var result: [String: [String]] = [:] @@ -1172,7 +1171,10 @@ final class MainContentCoordinator { } } - if result.isEmpty, let createSQL = try? await driver.fetchTableDDL(table: tableName) { + if result.isEmpty, + let createSQL = try? await DatabaseManager.shared.withMetadataDriver(connectionId: connectionId, { driver in + try await driver.fetchTableDDL(table: tableName) + }) { for col in columnInfo { if let values = QuerySqlParser.parseSQLiteCheckConstraintValues( createSQL: createSQL, columnName: col.name diff --git a/TablePro/Views/Structure/TableStructureView+DataLoading.swift b/TablePro/Views/Structure/TableStructureView+DataLoading.swift index 028ced7ce..920ac4470 100644 --- a/TablePro/Views/Structure/TableStructureView+DataLoading.swift +++ b/TablePro/Views/Structure/TableStructureView+DataLoading.swift @@ -28,14 +28,10 @@ extension TableStructureView { isLoading = true errorMessage = nil - guard let driver = DatabaseManager.shared.driver(for: connection.id) else { - errorMessage = String(localized: "Not connected") - isLoading = false - return - } - do { - columns = try await driver.fetchColumns(table: tableName) + columns = try await DatabaseManager.shared.withMetadataDriver(connectionId: connection.id) { driver in + try await driver.fetchColumns(table: tableName) + } loadedTabs.insert(.columns) } catch { errorMessage = error.localizedDescription @@ -50,23 +46,28 @@ extension TableStructureView { } func fetchTabData(_ tab: StructureTab) async { - guard let driver = DatabaseManager.shared.driver(for: connection.id) else { return } - do { switch tab { case .columns: - columns = try await driver.fetchColumns(table: tableName) + columns = try await DatabaseManager.shared.withMetadataDriver(connectionId: connection.id) { driver in + try await driver.fetchColumns(table: tableName) + } case .indexes: - indexes = try await driver.fetchIndexes(table: tableName) + indexes = try await DatabaseManager.shared.withMetadataDriver(connectionId: connection.id) { driver in + try await driver.fetchIndexes(table: tableName) + } case .foreignKeys: - foreignKeys = try await driver.fetchForeignKeys(table: tableName) + foreignKeys = try await DatabaseManager.shared.withMetadataDriver(connectionId: connection.id) { driver in + try await driver.fetchForeignKeys(table: tableName) + } case .ddl: - let sequences = try await driver.fetchDependentSequences(forTable: tableName) - let enumTypes = try await driver.fetchDependentTypes(forTable: tableName) - let baseDDL = try await driver.fetchTableDDL(table: tableName) - if sequences.isEmpty && enumTypes.isEmpty { - ddlStatement = baseDDL - } else { + ddlStatement = try await DatabaseManager.shared.withMetadataDriver(connectionId: connection.id) { driver in + let sequences = try await driver.fetchDependentSequences(forTable: tableName) + let enumTypes = try await driver.fetchDependentTypes(forTable: tableName) + let baseDDL = try await driver.fetchTableDDL(table: tableName) + if sequences.isEmpty && enumTypes.isEmpty { + return baseDDL + } var preamble = "" for seq in sequences { preamble += seq.ddl + "\n\n" @@ -76,7 +77,7 @@ extension TableStructureView { let quotedLabels = enumType.labels.map { "'\(SQLEscaping.escapeStringLiteral($0))'" } preamble += "CREATE TYPE \(quotedName) AS ENUM (\(quotedLabels.joined(separator: ", ")));\n" } - ddlStatement = preamble + "\n" + baseDDL + return preamble + "\n" + baseDDL } case .parts: return diff --git a/TablePro/Views/Structure/TableStructureView+Schema.swift b/TablePro/Views/Structure/TableStructureView+Schema.swift index 46f341114..177b18b4b 100644 --- a/TablePro/Views/Structure/TableStructureView+Schema.swift +++ b/TablePro/Views/Structure/TableStructureView+Schema.swift @@ -102,14 +102,15 @@ extension TableStructureView { await loadColumns() // Load indexes and foreign keys (needed for complete schema state) - guard let driver = DatabaseManager.shared.driver(for: connection.id) else { - isReloadingAfterSave = false - return - } do { - indexes = try await driver.fetchIndexes(table: tableName) + let (reloadedIndexes, reloadedFKs) = try await DatabaseManager.shared.withMetadataDriver(connectionId: connection.id) { driver in + let reloadedIndexes = try await driver.fetchIndexes(table: tableName) + let reloadedFKs = try await driver.fetchForeignKeys(table: tableName) + return (reloadedIndexes, reloadedFKs) + } + indexes = reloadedIndexes loadedTabs.insert(.indexes) - foreignKeys = try await driver.fetchForeignKeys(table: tableName) + foreignKeys = reloadedFKs loadedTabs.insert(.foreignKeys) } catch { Self.logger.error("Failed to reload indexes/FKs: \(error.localizedDescription, privacy: .public)") diff --git a/TableProTests/Core/Autocomplete/SQLSchemaProviderTests.swift b/TableProTests/Core/Autocomplete/SQLSchemaProviderTests.swift index f170f88f3..a27d1cf9a 100644 --- a/TableProTests/Core/Autocomplete/SQLSchemaProviderTests.swift +++ b/TableProTests/Core/Autocomplete/SQLSchemaProviderTests.swift @@ -6,9 +6,9 @@ // import Foundation +@testable import TablePro import TableProPluginKit import Testing -@testable import TablePro // MARK: - Mock Driver @@ -333,4 +333,38 @@ struct SQLSchemaProviderTests { #expect(items[0].label == "users.id") #expect(items[1].label == "orders.id") } + + @Test("getColumns uses injected metadata source instead of cached driver") + func getColumnsUsesMetadataSource() async { + let driver = MockDatabaseDriver() + driver.columnsToReturn = ["users": [TestFixtures.makeColumnInfo(name: "from_driver")]] + let source = SQLSchemaProvider.ColumnMetadataSource( + fetchColumns: { _ in [TestFixtures.makeColumnInfo(name: "from_source")] }, + fetchAllColumns: { [:] } + ) + let provider = SQLSchemaProvider(metadataSource: source) + await provider.resetForDatabase("db", tables: [TestFixtures.makeTableInfo(name: "users")], driver: driver) + + let columns = await provider.getColumns(for: "users") + #expect(columns.first?.name == "from_source") + #expect(driver.fetchColumnsCallCount == 0) + } + + @Test("eager column load uses injected metadata source instead of cached driver") + func eagerLoadUsesMetadataSource() async throws { + let driver = MockDatabaseDriver() + driver.columnsToReturn = ["users": [TestFixtures.makeColumnInfo(name: "from_driver")]] + let source = SQLSchemaProvider.ColumnMetadataSource( + fetchColumns: { _ in [TestFixtures.makeColumnInfo(name: "lazy_source")] }, + fetchAllColumns: { ["users": [TestFixtures.makeColumnInfo(name: "eager_source")]] } + ) + let provider = SQLSchemaProvider(metadataSource: source) + await provider.resetForDatabase("db", tables: [TestFixtures.makeTableInfo(name: "users")], driver: driver) + + try await Task.sleep(nanoseconds: 300_000_000) + + let columns = await provider.getColumns(for: "users") + #expect(columns.first?.name == "eager_source") + #expect(driver.fetchColumnsCallCount == 0) + } }