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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Select several databases or schemas in the sidebar tree and act on them at once: drop, refresh, copy names, or export. Shift-click and Cmd-click extend the selection.
- The database switcher takes a multiple selection too, with the same actions on the right-click menu.
- Drop Schema for PostgreSQL, SQL Server and SurrealDB.
- Quick Switcher can search tables and views across every open connection.
- SSL settings on mobile for MySQL, PostgreSQL and Redis: a mode picker plus CA, client certificate and client key. Import a PEM file, paste one, or use a PKCS#12 file. (#2083)
- Legacy UUID Encoding on a MongoDB connection, so binary UUIDs written by the Java, C# or Python drivers read as UUIDs instead of hex. Filters, edits and MQL exports write the same bytes back. (#2086)

Expand Down Expand Up @@ -47,6 +48,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- Option+Return in the Quick Switcher opens the selected table in a new window tab. The shortcut was documented but never fired.
- Right-clicking a table that is not part of the current selection now acts on that table. It used to act on the selected tables instead, including for Delete.
- Database icons and the current-database checkmark stay visible on a selected row in the database switcher. They were drawn in the accent colour, which vanished against the accent-coloured selection.
- The sidebar drops a database from the tree as soon as you drop it on the server, instead of listing it until you reconnect.
Expand Down
7 changes: 7 additions & 0 deletions TablePro/Core/Concurrency/OnceTask.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,13 @@ actor OnceTask<Key: Hashable & Sendable, Value: Sendable> {
inFlight.removeValue(forKey: key)
}

func cancel(where predicate: (Key) -> Bool) {
for key in inFlight.keys where predicate(key) {
inFlight[key]?.task.cancel()
inFlight.removeValue(forKey: key)
}
}

func cancelAll() {
for entry in inFlight.values {
entry.task.cancel()
Expand Down
81 changes: 78 additions & 3 deletions TablePro/Core/Services/Query/SchemaRefreshService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,77 @@ final class SchemaRefreshService {
inFlight.removeValue(forKey: key)
}

func waitForRefresh(connectionId: UUID) async {
let tasks = inFlight.compactMap { key, task in
key.connectionId == connectionId ? task : nil
}
for task in tasks {
await task.value
}
}

/// Brings every listed connection's catalog up to the scope that connection is browsing.
/// Connections are independent, so they load concurrently rather than one after another.
/// Returns the connections whose catalog ended up matching their browse scope.
func loadBrowseCatalogs(connectionIds: [UUID]) async -> Set<UUID> {
await withTaskGroup(of: (UUID, Bool).self) { group in
for connectionId in connectionIds {
group.addTask { @MainActor in
(connectionId, await self.loadBrowseCatalog(connectionId: connectionId))
}
}
var loaded: Set<UUID> = []
for await (connectionId, didLoad) in group where didLoad {
loaded.insert(connectionId)
}
return loaded
}
}

private func loadBrowseCatalog(connectionId: UUID) async -> Bool {
await waitForRefresh(connectionId: connectionId)
await schemaService.waitForRefresh(connectionId: connectionId)
guard !Task.isCancelled,
let session = databaseManager?.session(for: connectionId),
session.isConnected,
session.driver != nil,
let scope = metadataDriverProvider.browseScope(for: connectionId) else { return false }

if schemaService.loadedScope(for: connectionId) != scope {
await refresh(connection: session.connection)
await waitForRefresh(connectionId: connectionId)
await schemaService.waitForRefresh(connectionId: connectionId)
}

guard !Task.isCancelled, schemaService.loadedScope(for: connectionId) == scope else { return false }
guard pluginManager.databaseGroupingStrategy(for: session.connection.type) == .hierarchicalSchema else {
return true
}
return await loadBrowsedSchemaTables(connectionId: connectionId, scope: scope)
}

/// A hierarchicalSchema plugin loads objects one schema at a time, so a loaded scope on its
/// own means the schema list arrived, not that any schema holds objects. Without this, a
/// connection whose browsed schema was never expanded reports a full catalog of nothing.
private func loadBrowsedSchemaTables(connectionId: UUID, scope: DatabaseScope) async -> Bool {
guard let schema = scope.schema else { return false }
if schemaService.hasLoadedContent(for: connectionId, schema: schema) { return true }
do {
try await metadataDriverProvider.withMetadataDriver(
scope: scope,
workload: .bulk
) { [schemaService] driver in
await schemaService.loadSchemaTables(connectionId: connectionId, schema: schema, driver: driver)
}
} catch {
Self.logger.warning(
"[schema] browsed schema load failed connId=\(connectionId, privacy: .public) schema=\(schema, privacy: .public) error=\(error.localizedDescription, privacy: .public)"
)
return false
}
return schemaService.hasLoadedContent(for: connectionId, schema: schema)
}

/// Push the loaded table list into the autocomplete provider.
///
/// The provider caches the driver it is handed and fetches columns from it later, so it
Expand Down Expand Up @@ -120,14 +191,18 @@ final class SchemaRefreshService {
}

do {
try await metadataDriverProvider.withBrowseMetadataDriver(
connectionId: connectionId,
guard let scope = metadataDriverProvider.browseScope(for: connectionId) else {
throw DatabaseError.notConnected
}
try await metadataDriverProvider.withMetadataDriver(
scope: scope,
workload: .bulk
) { [schemaService] driver in
await schemaService.reload(
connectionId: connectionId,
driver: driver,
connection: connection
connection: connection,
scope: scope
)
await schemaService.refreshLoadedSchemaTables(
connectionId: connectionId,
Expand Down
123 changes: 105 additions & 18 deletions TablePro/Core/Services/Query/SchemaService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ final class SchemaService {
private(set) var perSchemaStates: [UUID: [String: SchemaState]] = [:]
private(set) var generations: [UUID: Int] = [:]
private(set) var refreshingConnections: Set<UUID> = []
private(set) var loadedScopes: [UUID: DatabaseScope] = [:]

func generationToken(for connectionId: UUID) -> Int {
generations[connectionId] ?? 0
Expand All @@ -28,7 +29,7 @@ final class SchemaService {
generations[connectionId, default: 0] &+= 1
}

@ObservationIgnored private let loadDedup = OnceTask<UUID, [TableInfo]>()
@ObservationIgnored private let loadDedup = OnceTask<LoadKey, [TableInfo]>()
@ObservationIgnored private let procedureDedup = OnceTask<UUID, [RoutineInfo]>()
@ObservationIgnored private let functionDedup = OnceTask<UUID, [RoutineInfo]>()
@ObservationIgnored private let schemasDedup = OnceTask<UUID, [String]>()
Expand All @@ -38,7 +39,21 @@ final class SchemaService {
let connectionId: UUID
let schema: String
}

/// Two windows browsing the same scope share one fetch; two windows browsing different
/// scopes must not, or the second stamps the first's tables with its own scope.
struct LoadKey: Hashable, Sendable {
let connectionId: UUID
let scope: DatabaseScope?
}

private struct RefreshWaiter {
let id: UUID
let continuation: CheckedContinuation<Void, Never>
}

@ObservationIgnored private var loadGenerations: [UUID: Int] = [:]
@ObservationIgnored private var refreshWaiters: [UUID: [RefreshWaiter]] = [:]
@ObservationIgnored private var nextLoadGeneration = 0
@ObservationIgnored private static let logger = Logger(subsystem: "com.TablePro", category: "SchemaService")

Expand All @@ -50,6 +65,30 @@ final class SchemaService {
refreshingConnections.contains(connectionId)
}

func loadedScope(for connectionId: UUID) -> DatabaseScope? {
loadedScopes[connectionId]
}

func waitForRefresh(connectionId: UUID) async {
while refreshingConnections.contains(connectionId), !Task.isCancelled {
let waiterId = UUID()
await withTaskCancellationHandler {
await withCheckedContinuation { continuation in
guard refreshingConnections.contains(connectionId), !Task.isCancelled else {
continuation.resume()
return
}
refreshWaiters[connectionId, default: []]
.append(RefreshWaiter(id: waiterId, continuation: continuation))
}
} onCancel: {
Task { @MainActor [weak self] in
self?.resumeRefreshWaiter(connectionId, id: waiterId)
}
}
}
}

func hasLoadedContent(for connectionId: UUID) -> Bool {
if case .loaded = state(for: connectionId) { return true }
return false
Expand Down Expand Up @@ -158,17 +197,27 @@ final class SchemaService {
bumpGeneration(connectionId)
}

func load(connectionId: UUID, driver: DatabaseDriver, connection: DatabaseConnection) async {
func load(
connectionId: UUID,
driver: DatabaseDriver,
connection: DatabaseConnection,
scope: DatabaseScope? = nil
) async {
switch state(for: connectionId) {
case .loaded:
case .loaded where scope == nil || loadedScopes[connectionId] == scope:
return
case .idle, .loading, .failed:
await runLoad(connectionId: connectionId, driver: driver, connection: connection)
case .idle, .loading, .failed, .loaded:
await runLoad(connectionId: connectionId, driver: driver, connection: connection, scope: scope)
}
}

func reload(connectionId: UUID, driver: DatabaseDriver, connection: DatabaseConnection) async {
await runLoad(connectionId: connectionId, driver: driver, connection: connection)
func reload(
connectionId: UUID,
driver: DatabaseDriver,
connection: DatabaseConnection,
scope: DatabaseScope? = nil
) async {
await runLoad(connectionId: connectionId, driver: driver, connection: connection, scope: scope)
}

func reloadProcedures(connectionId: UUID, driver: DatabaseDriver) async {
Expand Down Expand Up @@ -210,15 +259,11 @@ final class SchemaService {
}

private func cancelInFlightLoads(connectionId: UUID) async {
await loadDedup.cancel(key: connectionId)
await loadDedup.cancel { $0.connectionId == connectionId }
await procedureDedup.cancel(key: connectionId)
await functionDedup.cancel(key: connectionId)
await schemasDedup.cancel(key: connectionId)
if let schemas = perSchemaStates[connectionId]?.keys {
for schema in schemas {
await perSchemaDedup.cancel(key: SchemaKey(connectionId: connectionId, schema: schema))
}
}
await perSchemaDedup.cancel { $0.connectionId == connectionId }
}

func invalidate(connectionId: UUID) async {
Expand All @@ -231,6 +276,8 @@ final class SchemaService {
schemasInOrder.removeValue(forKey: connectionId)
perSchemaStates.removeValue(forKey: connectionId)
generations.removeValue(forKey: connectionId)
loadedScopes.removeValue(forKey: connectionId)
resumeRefreshWaiters(connectionId)
}

func refresh(connectionId: UUID) async {
Expand All @@ -243,7 +290,12 @@ final class SchemaService {
return
}
await prepareForReload(connectionId: connectionId)
await reload(connectionId: connectionId, driver: driver, connection: session.connection)
await reload(
connectionId: connectionId,
driver: driver,
connection: session.connection,
scope: DatabaseManager.shared.browseScope(for: connectionId)
)
}

func markLoadFailed(connectionId: UUID, message: String) {
Expand All @@ -255,7 +307,8 @@ final class SchemaService {
private func runLoad(
connectionId: UUID,
driver: DatabaseDriver,
connection: DatabaseConnection
connection: DatabaseConnection,
scope: DatabaseScope?
) async {
let generation = beginLoadGeneration(for: connectionId)
beginRefresh(connectionId)
Expand All @@ -272,11 +325,18 @@ final class SchemaService {

let grouping = PluginManager.shared.databaseGroupingStrategy(for: connection.type)
if grouping == .hierarchicalSchema {
await runHierarchicalLoad(connectionId: connectionId, driver: driver, generation: generation)
await runHierarchicalLoad(
connectionId: connectionId,
driver: driver,
generation: generation,
scope: scope
)
return
}

async let tablesTask: [TableInfo] = loadDedup.execute(key: connectionId) {
async let tablesTask: [TableInfo] = loadDedup.execute(
key: LoadKey(connectionId: connectionId, scope: scope)
) {
try await driver.fetchTables()
}
async let proceduresTask: [RoutineInfo] = Self.fetchRoutinesSafely(
Expand Down Expand Up @@ -324,6 +384,9 @@ final class SchemaService {
}
schemasInOrder[connectionId] = loadedSchemas
}
if let scope {
loadedScopes[connectionId] = scope
}
bumpGeneration(connectionId)
} catch is CancellationError {
return
Expand All @@ -341,7 +404,12 @@ final class SchemaService {
}
}

private func runHierarchicalLoad(connectionId: UUID, driver: DatabaseDriver, generation: Int) async {
private func runHierarchicalLoad(
connectionId: UUID,
driver: DatabaseDriver,
generation: Int,
scope: DatabaseScope?
) async {
async let proceduresTask: [RoutineInfo] = Self.fetchRoutinesSafely(
connectionId: connectionId,
kind: .procedure,
Expand Down Expand Up @@ -383,6 +451,9 @@ final class SchemaService {
procedures[connectionId] = loadedProcedures
functions[connectionId] = loadedFunctions
states[connectionId] = .loaded([])
if let scope {
loadedScopes[connectionId] = scope
}
bumpGeneration(connectionId)
}

Expand All @@ -393,6 +464,22 @@ final class SchemaService {
private func endRefresh(_ connectionId: UUID, generation: Int) {
guard loadGenerations[connectionId] == generation else { return }
refreshingConnections.remove(connectionId)
resumeRefreshWaiters(connectionId)
}

private func resumeRefreshWaiters(_ connectionId: UUID) {
let waiters = refreshWaiters.removeValue(forKey: connectionId) ?? []
for waiter in waiters {
waiter.continuation.resume()
}
}

private func resumeRefreshWaiter(_ connectionId: UUID, id: UUID) {
guard var waiters = refreshWaiters[connectionId],
let index = waiters.firstIndex(where: { $0.id == id }) else { return }
let waiter = waiters.remove(at: index)
refreshWaiters[connectionId] = waiters.isEmpty ? nil : waiters
waiter.continuation.resume()
}

private func beginLoadGeneration(for connectionId: UUID) -> Int {
Expand Down
Loading
Loading