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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- The filter panel's "Unset" button is now "Clear". It keeps your filter rows and only drops the applied state. To remove the rows, use "Remove All Filters" in the filter options menu.
- A row's right-click menu now has "Apply Only This Filter". The inline per-row Apply button is gone.

### Changed

- Expanding a database in the tree sidebar loads tables first and fills in procedures and functions in the background, so the table list appears after one round-trip instead of waiting for three queries to finish in sequence.

### Fixed

- Expanding or collapsing a database or schema in the tree sidebar while its tables were still loading could crash the app. The tree now updates its rows without rebuilding the outline structure.
- MongoDB filters on `_id` and other ObjectId fields now match. A 24-character hex value is matched as an ObjectId as well as a string, so filtering by `_id` returns the row instead of nothing. (#1682)
- Shift+Arrow in the data grid now starts and extends a cell selection from the focused cell. Cmd+Shift+Arrow extends to the row or column edge.
- Delete key now removes all rows covered by a cell-range selection instead of ignoring it.
- Right-clicking inside a multi-row or cell-range selection no longer collapses the selection first.
Expand Down
124 changes: 88 additions & 36 deletions TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,18 +23,15 @@ final class DatabaseTreeMetadataService {
let schema: String?
}

struct SchemaObjects: Equatable, Sendable {
var tables: [TableInfo]
var routines: [RoutineInfo]
}

private(set) var databaseList: [UUID: MetadataLoadState<[DatabaseMetadata]>] = [:]
private(set) var schemaList: [DatabaseKey: MetadataLoadState<[String]>] = [:]
private(set) var objects: [ObjectsKey: MetadataLoadState<SchemaObjects>] = [:]
private(set) var tablesState: [ObjectsKey: MetadataLoadState<[TableInfo]>] = [:]
private(set) var routinesState: [ObjectsKey: MetadataLoadState<[RoutineInfo]>] = [:]

@ObservationIgnored private let databaseDedup = OnceTask<UUID, [DatabaseMetadata]>()
@ObservationIgnored private let schemaDedup = OnceTask<DatabaseKey, [String]>()
@ObservationIgnored private let objectsDedup = OnceTask<ObjectsKey, SchemaObjects>()
@ObservationIgnored private let tablesDedup = OnceTask<ObjectsKey, [TableInfo]>()
@ObservationIgnored private let routinesDedup = OnceTask<ObjectsKey, [RoutineInfo]>()

@ObservationIgnored private static let logger = Logger(
subsystem: "com.TablePro", category: "SidebarTree"
Expand All @@ -60,16 +57,20 @@ final class DatabaseTreeMetadataService {
schemaList[DatabaseKey(connectionId: connectionId, database: database)]?.value ?? []
}

func objectsState(connectionId: UUID, database: String, schema: String?) -> MetadataLoadState<SchemaObjects> {
objects[Self.objectsKey(connectionId: connectionId, database: database, schema: schema)] ?? .idle
func tablesLoadState(connectionId: UUID, database: String, schema: String?) -> MetadataLoadState<[TableInfo]> {
tablesState[Self.objectsKey(connectionId: connectionId, database: database, schema: schema)] ?? .idle
}

func routinesLoadState(connectionId: UUID, database: String, schema: String?) -> MetadataLoadState<[RoutineInfo]> {
routinesState[Self.objectsKey(connectionId: connectionId, database: database, schema: schema)] ?? .idle
}

func tables(connectionId: UUID, database: String, schema: String?) -> [TableInfo] {
objects[Self.objectsKey(connectionId: connectionId, database: database, schema: schema)]?.value?.tables ?? []
tablesState[Self.objectsKey(connectionId: connectionId, database: database, schema: schema)]?.value ?? []
}

func routines(connectionId: UUID, database: String, schema: String?) -> [RoutineInfo] {
objects[Self.objectsKey(connectionId: connectionId, database: database, schema: schema)]?.value?.routines ?? []
routinesState[Self.objectsKey(connectionId: connectionId, database: database, schema: schema)]?.value ?? []
}

// MARK: - Loads
Expand Down Expand Up @@ -122,34 +123,61 @@ final class DatabaseTreeMetadataService {
}
}

func loadObjects(connectionId: UUID, database: String, schema: String?) async {
func loadTables(connectionId: UUID, database: String, schema: String?) async {
guard isConnected(connectionId) else { return }
let key = Self.objectsKey(connectionId: connectionId, database: database, schema: schema)
switch objects[key] ?? .idle {
switch tablesState[key] ?? .idle {
case .loaded, .loading: return
case .idle, .failed: break
}
objects[key] = .loading
tablesState[key] = .loading
let normalizedSchema = key.schema
do {
let result = try await objectsDedup.execute(key: key) { [self] in
let list = try await tablesDedup.execute(key: key) { [self] in
try await withDriver(connectionId: connectionId, database: database) { driver in
async let tables = driver.fetchTables(schema: normalizedSchema)
async let procedures = driver.fetchProcedures(schema: normalizedSchema)
async let functions = driver.fetchFunctions(schema: normalizedSchema)
return SchemaObjects(
tables: try await tables,
routines: try await procedures + functions
)
try await driver.fetchTables(schema: normalizedSchema)
}
}
tablesState[key] = .loaded(list)
} catch is CancellationError {
if case .loading = tablesState[key] { tablesState[key] = .idle }
} catch {
tablesState[key] = .failed(error.localizedDescription)
Self.logger.warning(
"tables load failed db=\(database, privacy: .public) schema=\(schema ?? "nil", privacy: .public) error=\(error.localizedDescription, privacy: .public)"
)
}
}

func loadRoutines(connectionId: UUID, database: String, schema: String?) async {
guard isConnected(connectionId) else { return }
let key = Self.objectsKey(connectionId: connectionId, database: database, schema: schema)
switch routinesState[key] ?? .idle {
case .loaded, .loading: return
case .idle, .failed: break
}
routinesState[key] = .loading
let normalizedSchema = key.schema
do {
let list = try await routinesDedup.execute(key: key) { [self] in
try await MetadataConnectionPool.shared.withDriver(
connectionId: connectionId,
database: database,
schema: normalizedSchema,
workload: .bulk
) { driver in
let procedures = try await driver.fetchProcedures(schema: normalizedSchema)
let functions = try await driver.fetchFunctions(schema: normalizedSchema)
return procedures + functions
}
}
objects[key] = .loaded(result)
routinesState[key] = .loaded(list)
} catch is CancellationError {
if case .loading = objects[key] { objects[key] = .idle }
if case .loading = routinesState[key] { routinesState[key] = .idle }
} catch {
objects[key] = .failed(error.localizedDescription)
routinesState[key] = .failed(error.localizedDescription)
Self.logger.warning(
"objects load failed db=\(database, privacy: .public) schema=\(schema ?? "nil", privacy: .public) error=\(error.localizedDescription, privacy: .public)"
"routines load failed db=\(database, privacy: .public) schema=\(schema ?? "nil", privacy: .public) error=\(error.localizedDescription, privacy: .public)"
)
}
}
Expand All @@ -171,9 +199,13 @@ final class DatabaseTreeMetadataService {

func refreshObjects(connectionId: UUID, database: String, schema: String?) async {
let key = Self.objectsKey(connectionId: connectionId, database: database, schema: schema)
await objectsDedup.cancel(key: key)
objects.removeValue(forKey: key)
await loadObjects(connectionId: connectionId, database: database, schema: schema)
await tablesDedup.cancel(key: key)
await routinesDedup.cancel(key: key)
tablesState.removeValue(forKey: key)
routinesState.removeValue(forKey: key)
async let tables = loadTables(connectionId: connectionId, database: database, schema: schema)
async let routines = loadRoutines(connectionId: connectionId, database: database, schema: schema)
_ = await (tables, routines)
}

// MARK: - Lifecycle
Expand All @@ -186,34 +218,46 @@ final class DatabaseTreeMetadataService {
func handleDisconnect(connectionId: UUID) async {
MetadataConnectionPool.shared.closeAll(connectionId: connectionId)
let schemaKeys = schemaList.keys.filter { $0.connectionId == connectionId }
let objectKeys = objects.keys.filter { $0.connectionId == connectionId }
let objectKeys = Self.connectionObjectKeys(
tableKeys: tablesState.keys, routineKeys: routinesState.keys, connectionId: connectionId
)
await databaseDedup.cancel(key: connectionId)
for key in schemaKeys { await schemaDedup.cancel(key: key) }
for key in objectKeys { await objectsDedup.cancel(key: key) }
for key in objectKeys {
await tablesDedup.cancel(key: key)
await routinesDedup.cancel(key: key)
}
databaseList.removeValue(forKey: connectionId)
schemaList = schemaList.filter { $0.key.connectionId != connectionId }
objects = objects.filter { $0.key.connectionId != connectionId }
tablesState = tablesState.filter { $0.key.connectionId != connectionId }
routinesState = routinesState.filter { $0.key.connectionId != connectionId }
}

// MARK: - Private

private func resetPending(connectionId: UUID) async {
let schemaKeys = schemaList.keys.filter { $0.connectionId == connectionId }
let objectKeys = objects.keys.filter { $0.connectionId == connectionId }
let objectKeys = Self.connectionObjectKeys(
tableKeys: tablesState.keys, routineKeys: routinesState.keys, connectionId: connectionId
)

if isPending(databaseList[connectionId]) {
await databaseDedup.cancel(key: connectionId)
}
for key in schemaKeys where isPending(schemaList[key]) {
await schemaDedup.cancel(key: key)
}
for key in objectKeys where isPending(objects[key]) {
await objectsDedup.cancel(key: key)
for key in objectKeys {
if isPending(tablesState[key]) { await tablesDedup.cancel(key: key) }
if isPending(routinesState[key]) { await routinesDedup.cancel(key: key) }
}

if isPending(databaseList[connectionId]) { databaseList[connectionId] = .idle }
for key in schemaKeys where isPending(schemaList[key]) { schemaList[key] = .idle }
for key in objectKeys where isPending(objects[key]) { objects[key] = .idle }
for key in objectKeys {
if isPending(tablesState[key]) { tablesState[key] = .idle }
if isPending(routinesState[key]) { routinesState[key] = .idle }
}
}

private func isPending<Value>(_ state: MetadataLoadState<Value>?) -> Bool {
Expand Down Expand Up @@ -247,4 +291,12 @@ final class DatabaseTreeMetadataService {
let normalized: String? = (schema?.isEmpty == true) ? nil : schema
return ObjectsKey(connectionId: connectionId, database: database, schema: normalized)
}

static func connectionObjectKeys(
tableKeys: some Sequence<ObjectsKey>,
routineKeys: some Sequence<ObjectsKey>,
connectionId: UUID
) -> [ObjectsKey] {
Array(Set(tableKeys).union(routineKeys)).filter { $0.connectionId == connectionId }
}
}
Loading
Loading