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 @@ -82,6 +82,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- Autocomplete offering another schema's tables without their schema once that schema was completed or expanded.
- Tables in an expanded Oracle or Snowflake schema missing from Open Quickly until the next refresh.
- Tables from the previous database listed under a schema after switching database on Snowflake or Trino.
- Schemas missing from Open Quickly on every reopen after one failed to load.
- Unexpanded schemas hidden by the sidebar filter in the Tree layout.
- Empty object sections opened as "No items" under every match while filtering the sidebar tree.
Expand Down
7 changes: 2 additions & 5 deletions TablePro/Core/Services/Query/SchemaRefreshService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ final class SchemaRefreshService {
scope: scope,
workload: .bulk
) { [schemaService] driver in
await schemaService.loadSchemaObjects(connectionId: connectionId, schema: schema, driver: driver)
await schemaService.loadSchemaObjects(schema: schema, in: scope, driver: driver)
}
} catch {
Self.logger.warning(
Expand Down Expand Up @@ -309,10 +309,7 @@ final class SchemaRefreshService {
connection: connection,
scope: scope
)
await schemaService.refreshLoadedSchemaObjects(
connectionId: connectionId,
driver: driver
)
await schemaService.refreshLoadedSchemaObjects(in: scope, driver: driver)
}
} catch is CancellationError {
return
Expand Down
205 changes: 127 additions & 78 deletions TablePro/Core/Services/Query/SchemaService.swift

Large diffs are not rendered by default.

14 changes: 3 additions & 11 deletions TablePro/ViewModels/QuickSwitcherViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -428,8 +428,7 @@ internal final class QuickSwitcherViewModel: ObservableObject {
loadedFrom: loadedScope?.database,
coveredSchemas: coveredSchemas(loadedScope: loadedScope, grouping: tableSource.grouping),
listing: listing,
browsing: tableSource.database,
grouping: tableSource.grouping
browsing: tableSource.database
)
return Self.makeTableItems(
tables,
Expand Down Expand Up @@ -646,21 +645,14 @@ internal final class QuickSwitcherViewModel: ObservableObject {
/// `coveredSchemas` names the schemas the schema service answers for even when it found them
/// empty. Judged from its rows alone, a schema whose last table was dropped would have no rows,
/// so no say, and the listing's stale copy of that table would come back.
///
/// A hierarchical engine is the exception. Its per-schema lists are keyed by schema alone and
/// keep the rows of a database the connection has just switched away from until each one
/// reloads, so once the listing, which is keyed by database, has arrived it answers for every
/// schema, and the schema service only stands in until then.
nonisolated static func mergedTables(
local loaded: [TableInfo],
loadedFrom loadedDatabase: String?,
coveredSchemas: Set<String>,
listing: [TableInfo]?,
browsing database: String?,
grouping: GroupingStrategy
browsing database: String?
) -> [TableInfo] {
let listingAnswersAll = grouping == .hierarchicalSchema && listing != nil
let isCurrent = loadedDatabase == database && !listingAnswersAll
let isCurrent = loadedDatabase == database
let local = isCurrent ? loaded : []
let authoritative = (isCurrent ? coveredSchemas : []).union(local.map { $0.schema ?? "" })
var seen: Set<TableIdentity> = []
Expand Down
16 changes: 10 additions & 6 deletions TableProTests/Core/Services/Query/SchemaServiceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ import Testing
@Suite("SchemaService")
@MainActor
struct SchemaServiceTests {
private func unnamedDatabase(_ connectionId: UUID) -> DatabaseScope {
DatabaseScope(connectionId: connectionId, database: "", schema: nil)
}

@Test("allLoadedTables unions tables across loaded per-schema lists")
func allLoadedTablesUnionsPerSchema() async {
let connectionId = UUID()
Expand All @@ -28,8 +32,8 @@ struct SchemaServiceTests {
]

let service = SchemaService()
await service.loadSchemaObjects(connectionId: connectionId, schema: "sales", driver: driver)
await service.loadSchemaObjects(connectionId: connectionId, schema: "hr", driver: driver)
await service.loadSchemaObjects(schema: "sales", in: unnamedDatabase(connectionId), driver: driver)
await service.loadSchemaObjects(schema: "hr", in: unnamedDatabase(connectionId), driver: driver)

let names = Set(service.allLoadedTables(for: connectionId).map(\.name))
#expect(names == ["orders", "leads", "employees"])
Expand All @@ -46,8 +50,8 @@ struct SchemaServiceTests {
]

let service = SchemaService()
await service.loadSchemaObjects(connectionId: connectionId, schema: "sales", driver: driver)
await service.loadSchemaObjects(connectionId: connectionId, schema: "mirror", driver: driver)
await service.loadSchemaObjects(schema: "sales", in: unnamedDatabase(connectionId), driver: driver)
await service.loadSchemaObjects(schema: "mirror", in: unnamedDatabase(connectionId), driver: driver)

let matching = service.allLoadedTables(for: connectionId).filter { $0.id == shared.id }
#expect(matching.count == 1)
Expand All @@ -63,8 +67,8 @@ struct SchemaServiceTests {
]

let service = SchemaService()
await service.loadSchemaObjects(connectionId: connectionId, schema: "a", driver: driver)
await service.loadSchemaObjects(connectionId: connectionId, schema: "a.b", driver: driver)
await service.loadSchemaObjects(schema: "a", in: unnamedDatabase(connectionId), driver: driver)
await service.loadSchemaObjects(schema: "a.b", in: unnamedDatabase(connectionId), driver: driver)

let loaded = service.allLoadedTables(for: connectionId)
#expect(loaded.count == 2)
Expand Down
264 changes: 264 additions & 0 deletions TableProTests/Services/SchemaServiceDatabaseSwitchTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,264 @@
//
// SchemaServiceDatabaseSwitchTests.swift
// TableProTests
//

import Foundation
@testable import TablePro
import TableProPluginKit
import Testing

private final class DatabaseCatalogDriver: DatabaseDriver, @unchecked Sendable {
let connection: DatabaseConnection
var status: ConnectionStatus = .connected
var serverVersion: String? { nil }

var schemasToReturn: [String] = []
var tablesBySchema: [String: [TableInfo]] = [:]
var tablesError: Error?
private(set) var tableFetches: [String] = []

var pausesNextTableFetch = false
var onTableFetchPaused: (@Sendable () -> Void)?
private var tableFetchGate: CheckedContinuation<Void, Never>?

init(connection: DatabaseConnection) {
self.connection = connection
}

func resumeTableFetch() {
tableFetchGate?.resume()
tableFetchGate = nil
}

func connect() async throws {}
func disconnect() {}
func testConnection() async throws -> Bool { true }
func applyQueryTimeout(_ seconds: Int) async throws {}

func execute(query: String) async throws -> QueryResult {
QueryResult(columns: [], columnTypes: [], rows: [], rowsAffected: 0, executionTime: 0, error: nil)
}

func executeParameterized(query: String, parameters: [Any?]) async throws -> QueryResult {
QueryResult(columns: [], columnTypes: [], rows: [], rowsAffected: 0, executionTime: 0, error: nil)
}

func executeUserQuery(query: String, rowCap: Int?, parameters: [Any?]?) async throws -> QueryResult {
QueryResult(columns: [], columnTypes: [], rows: [], rowsAffected: 0, executionTime: 0, error: nil)
}

func fetchSchemas() async throws -> [String] {
schemasToReturn
}

func fetchTables() async throws -> [TableInfo] { [] }

func fetchTables(schema: String?) async throws -> [TableInfo] {
let schema = schema ?? ""
tableFetches.append(schema)
if let tablesError { throw tablesError }
let snapshot = tablesBySchema[schema] ?? []
if pausesNextTableFetch {
pausesNextTableFetch = false
await withCheckedContinuation { continuation in
tableFetchGate = continuation
onTableFetchPaused?()
}
}
return snapshot
}

func fetchColumns(table: String) async throws -> [ColumnInfo] { [] }
func fetchIndexes(table: String) async throws -> [IndexInfo] { [] }
func fetchForeignKeys(table: String) async throws -> [ForeignKeyInfo] { [] }
func fetchApproximateRowCount(table: String) async throws -> Int? { nil }
func fetchTableDDL(table: String) async throws -> String { "" }
func fetchViewDefinition(view: String) async throws -> String { "" }

func fetchTableMetadata(tableName: String) async throws -> TableMetadata {
TableMetadata(
tableName: tableName, dataSize: nil, indexSize: nil, totalSize: nil,
avgRowLength: nil, rowCount: nil, comment: nil, engine: nil,
collation: nil, createTime: nil, updateTime: nil
)
}

func fetchDatabases() async throws -> [String] { [] }

func fetchDatabaseMetadata(_ database: String) async throws -> DatabaseMetadata {
DatabaseMetadata(
id: database, name: database, tableCount: nil, sizeBytes: nil,
lastAccessed: nil, isSystemDatabase: false, icon: "cylinder"
)
}

func cancelQuery() throws {}
func beginTransaction() async throws {}
func commitTransaction() async throws {}
func rollbackTransaction() async throws {}
}

/// Snowflake and Trino change database on a live connection, and a schema name such as `PUBLIC`
/// exists in every database they reach.
@Suite("SchemaService database switch")
@MainActor
struct SchemaServiceDatabaseSwitchTests {
private let connectionId = UUID()
private let boom = NSError(domain: "test", code: 1, userInfo: [NSLocalizedDescriptionKey: "boom"])

private var connection: DatabaseConnection {
TestFixtures.makeConnection(id: connectionId, type: .snowflake)
}

private func scope(_ database: String) -> DatabaseScope {
DatabaseScope(connectionId: connectionId, database: database, schema: nil)
}

private func table(_ name: String, _ schema: String) -> TableInfo {
TableInfo(name: name, type: .table, rowCount: nil, schema: schema)
}

private func driver(schemas: [String], tables: [String: [TableInfo]] = [:]) -> DatabaseCatalogDriver {
let driver = DatabaseCatalogDriver(connection: connection)
driver.schemasToReturn = schemas
driver.tablesBySchema = tables
return driver
}

private func browse(_ service: SchemaService, database: String, driver: DatabaseCatalogDriver) async {
await service.reload(connectionId: connectionId, driver: driver, connection: connection, scope: scope(database))
}

private func loadObjects(
_ service: SchemaService,
schema: String,
database: String,
driver: DatabaseCatalogDriver
) async {
await service.loadSchemaObjects(schema: schema, in: scope(database), driver: driver)
}

private func refreshObjects(_ service: SchemaService, database: String, driver: DatabaseCatalogDriver) async {
await service.refreshLoadedSchemaObjects(in: scope(database), driver: driver)
}

private func sales() -> DatabaseCatalogDriver {
driver(schemas: ["PUBLIC", "LEDGER"], tables: [
"PUBLIC": [table("ORDERS", "PUBLIC")],
"LEDGER": [table("ENTRIES", "LEDGER")]
])
}

private func marketing() -> DatabaseCatalogDriver {
driver(schemas: ["PUBLIC"], tables: ["PUBLIC": [table("CAMPAIGNS", "PUBLIC")]])
}

@Test("A schema of the database switched to never shows the tables of the one left")
func switchDoesNotCarryTablesAcross() async {
let service = SchemaService()
let salesDriver = sales()
await browse(service, database: "SALES", driver: salesDriver)
await loadObjects(service, schema: "PUBLIC", database: "SALES", driver: salesDriver)
#expect(service.tables(for: connectionId, schema: "PUBLIC").map(\.name) == ["ORDERS"])

await browse(service, database: "MARKETING", driver: marketing())

#expect(service.schemas(for: connectionId) == ["PUBLIC"])
#expect(service.tables(for: connectionId, schema: "PUBLIC").isEmpty)
#expect(service.schemaState(for: connectionId, schema: "PUBLIC") == .idle)
#expect(service.routinesLoadState(for: connectionId, schema: "PUBLIC") == .idle)
#expect(service.allLoadedTables(for: connectionId).isEmpty)
#expect(service.schemasWithLoadedTables(for: connectionId).isEmpty)
}

@Test("A schema whose load fails after a switch reports the failure, not the old database")
func failedLoadAfterSwitchShowsNoOldTables() async {
let service = SchemaService()
let salesDriver = sales()
await browse(service, database: "SALES", driver: salesDriver)
await loadObjects(service, schema: "PUBLIC", database: "SALES", driver: salesDriver)
let marketingDriver = marketing()
await browse(service, database: "MARKETING", driver: marketingDriver)

marketingDriver.tablesError = boom
await loadObjects(service, schema: "PUBLIC", database: "MARKETING", driver: marketingDriver)

#expect(marketingDriver.tableFetches == ["PUBLIC"])
#expect(service.tables(for: connectionId, schema: "PUBLIC").isEmpty)
#expect(service.schemaState(for: connectionId, schema: "PUBLIC") == .failed("boom"))
}

@Test("Refreshing after a switch reads nothing on behalf of the database left")
func refreshAfterSwitchReadsNothingForTheOldDatabase() async {
let service = SchemaService()
let salesDriver = sales()
await browse(service, database: "SALES", driver: salesDriver)
await loadObjects(service, schema: "PUBLIC", database: "SALES", driver: salesDriver)
await loadObjects(service, schema: "LEDGER", database: "SALES", driver: salesDriver)
let marketingDriver = marketing()
await browse(service, database: "MARKETING", driver: marketingDriver)

await refreshObjects(service, database: "MARKETING", driver: marketingDriver)

#expect(marketingDriver.tableFetches.isEmpty)
#expect(service.tables(for: connectionId, schema: "LEDGER").isEmpty)
}

@Test("A load for the database left that finishes after the switch is not shown")
func lateLoadFromTheOldDatabaseIsDiscarded() async {
let service = SchemaService()
let salesDriver = sales()
await browse(service, database: "SALES", driver: salesDriver)

salesDriver.pausesNextTableFetch = true
var late: Task<Void, Never>?
await withCheckedContinuation { (paused: CheckedContinuation<Void, Never>) in
salesDriver.onTableFetchPaused = { paused.resume() }
late = Task { await loadObjects(service, schema: "PUBLIC", database: "SALES", driver: salesDriver) }
}
await browse(service, database: "MARKETING", driver: marketing())
salesDriver.resumeTableFetch()
await late?.value

#expect(service.tables(for: connectionId, schema: "PUBLIC").isEmpty)
#expect(service.allLoadedTables(for: connectionId).isEmpty)
}

/// The sidebar reads the database being switched to as soon as the switch is made, while the
/// schema list of the one being left is still on screen.
@Test("Objects loaded for the new database before its schema list arrives are kept")
func loadForTheNewDatabaseDuringTheSwitchIsKept() async {
let service = SchemaService()
let salesDriver = sales()
await browse(service, database: "SALES", driver: salesDriver)
await loadObjects(service, schema: "PUBLIC", database: "SALES", driver: salesDriver)
let marketingDriver = marketing()

await loadObjects(service, schema: "PUBLIC", database: "MARKETING", driver: marketingDriver)
#expect(service.tables(for: connectionId, schema: "PUBLIC").map(\.name) == ["ORDERS"])

await browse(service, database: "MARKETING", driver: marketingDriver)
await loadObjects(service, schema: "PUBLIC", database: "MARKETING", driver: marketingDriver)

#expect(service.tables(for: connectionId, schema: "PUBLIC").map(\.name) == ["CAMPAIGNS"])
#expect(marketingDriver.tableFetches == ["PUBLIC"])
}

@Test("Switching back lists the first database's objects again once they are loaded")
func switchingBackReadsTheFirstDatabase() async {
let service = SchemaService()
let salesDriver = sales()
await browse(service, database: "SALES", driver: salesDriver)
await loadObjects(service, schema: "PUBLIC", database: "SALES", driver: salesDriver)
let marketingDriver = marketing()
await browse(service, database: "MARKETING", driver: marketingDriver)
await loadObjects(service, schema: "PUBLIC", database: "MARKETING", driver: marketingDriver)

await browse(service, database: "SALES", driver: salesDriver)
await loadObjects(service, schema: "PUBLIC", database: "SALES", driver: salesDriver)

#expect(service.tables(for: connectionId, schema: "PUBLIC").map(\.name) == ["ORDERS"])
#expect(salesDriver.tableFetches == ["PUBLIC", "PUBLIC"])
}
}
Loading
Loading