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

- Last line of a helper process's output lost when it exits right after writing it.
- Structure and trigger edits committing or rolling back a transaction left open in a query tab on the same connection.
- Composite, range and extension-typed PostgreSQL columns labelled `ENUM(…)` in the structure editor.
- Sidebar routines, triggers and types from the previous database after switching while it was still loading.
- Silent fallback order when foreign keys between the exported tables form a cycle. (#2517)
Expand Down
9 changes: 5 additions & 4 deletions TablePro/Core/Database/DatabaseManager+Schema.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,10 @@ import TableProPluginKit
// MARK: - Schema Changes

extension DatabaseManager {
/// Execute schema changes (ALTER TABLE, CREATE INDEX, etc.) in a transaction.
/// The connection, database and schema all come from the editing tab's own scope,
/// never from ambient session state that another window or tab can move.
/// Execute schema changes (ALTER TABLE, CREATE INDEX, etc.) in a transaction of their own,
/// on the schema change route rather than the session driver a query tab may have left
/// mid-transaction. The connection, database and schema all come from the editing tab's
/// own scope, never from ambient session state that another window or tab can move.
///
/// Authorization sits between two scoped blocks rather than inside one: it awaits a
/// confirmation sheet and Touch ID, and holding the connection's driver gate across a
Expand All @@ -26,7 +27,7 @@ extension DatabaseManager {
databaseType: DatabaseType,
scope: DatabaseScope
) async throws {
let route = executionRoute(for: scope)
let route = schemaChangeRoute(for: scope)

let statements = try await withScopedDriver(
scope: scope, route: route, cancellation: .untracked
Expand Down
10 changes: 10 additions & 0 deletions TablePro/Core/Database/DatabaseManager+ScopedDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,16 @@ extension DatabaseManager {
return canPool(session) ? .pooled : .sessionDriver
}

/// A structure, trigger or enum edit is the app's own DDL with its own BEGIN and COMMIT, so
/// it must not share a connection with the user: on the session driver its BEGIN joins
/// whatever transaction a query tab left open, and its COMMIT or ROLLBACK then takes that
/// tab's uncommitted work with it. It runs on a pooled connection wherever one reaches the
/// same database, which is the metadata route, and on the session driver only where nothing
/// else can.
func schemaChangeRoute(for scope: DatabaseScope) -> ScopedDriverRoute {
metadataRoute(for: scope)
}

/// SQL the user owns stays on the session driver, which holds their transaction,
/// their temp tables and the handle Stop cancels. The pool is the fallback only for
/// engines that cannot change database on a live connection, where the alternative
Expand Down
9 changes: 4 additions & 5 deletions TablePro/Core/Database/EnumLabelEditor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -83,15 +83,14 @@ struct EnumLabelEditor {
throw EnumLabelEditingError.denied(decision.deniedReason ?? String(localized: "Operation not permitted"))
}

/// Not the session driver: that one holds whatever transaction the user opened in a query
/// tab, and a label added inside it is unusable until the commit and gone on a rollback,
/// while the listing reloads over other connections and cannot see it at all. The
/// metadata route is a dedicated autocommit connection wherever the engine can pool one.
/// A label added inside a query tab's open transaction is unusable until the commit and
/// gone on a rollback, while the listing reloads over other connections and cannot see
/// it at all; the schema change route keeps the statement off that transaction.
let startedAt = Date()
let scope = scope
try await DatabaseManager.shared.withScopedDriver(
scope: scope,
route: DatabaseManager.shared.metadataRoute(for: scope),
route: DatabaseManager.shared.schemaChangeRoute(for: scope),
cancellation: .protectedWrite
) { driver in
_ = try await driver.execute(query: sql)
Expand Down
75 changes: 48 additions & 27 deletions TablePro/Core/Database/TriggerEditing.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,18 +43,18 @@ enum TriggerApplyStrategy: Equatable {
enum TriggerEditing {
nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "TriggerEditing")

/// Runs on the schema change route, never on the session driver: the trigger's own BEGIN
/// would join a transaction a query tab left open, and its COMMIT or ROLLBACK would take
/// that tab's uncommitted work with it.
static func apply(
scope: DatabaseScope,
connection: DatabaseConnection,
tableName: String,
sql: String,
isEdit: Bool,
originalName: String?,
originalDefinition: String?
) async throws {
guard let driver = DatabaseManager.shared.driver(for: connection.id) else {
throw TriggerEditingError.notConnected
}

let decision = await ExecutionGateProvider.shared.authorize(
OperationRequest(
connectionId: connection.id,
Expand All @@ -72,29 +72,30 @@ enum TriggerEditing {
throw TriggerEditingError.denied(decision.deniedReason ?? String(localized: "Operation not permitted"))
}

let strategy = TriggerApplyStrategy.resolve(
isEdit: isEdit,
usesReplace: driver.triggerEditUsesReplace,
transactionalDDL: driver.supportsTransactionalDDL
)
let dropSQL = originalName.flatMap { driver.generateDropTriggerSQL(name: $0, table: tableName) }

let startedAt = Date()
switch strategy {
case let .transactional(dropFirst):
try await runInTransaction(driver: driver, dropSQL: dropFirst ? dropSQL : nil, sql: sql)
case .dropThenCreate:
guard let dropSQL else { throw TriggerEditingError.dropUnavailable }
try await runDropThenCreate(driver: driver, dropSQL: dropSQL, sql: sql, rollback: originalDefinition)
case .direct:
_ = try await driver.execute(query: sql)
try await withSchemaChangeDriver(scope: scope) { driver in
let strategy = TriggerApplyStrategy.resolve(
isEdit: isEdit,
usesReplace: driver.triggerEditUsesReplace,
transactionalDDL: driver.supportsTransactionalDDL
)
let dropSQL = originalName.flatMap { driver.generateDropTriggerSQL(name: $0, table: tableName) }
switch strategy {
case let .transactional(dropFirst):
try await runInTransaction(driver: driver, dropSQL: dropFirst ? dropSQL : nil, sql: sql)
case .dropThenCreate:
guard let dropSQL else { throw TriggerEditingError.dropUnavailable }
try await runDropThenCreate(driver: driver, dropSQL: dropSQL, sql: sql, rollback: originalDefinition)
case .direct:
_ = try await driver.execute(query: sql)
}
}

await recordHistory(sql, connection: connection, executionTime: Date().timeIntervalSince(startedAt))
AppCommands.shared.refreshData.send(DataRefreshRequest(connectionId: connection.id))
await recordHistory(sql, scope: scope, connection: connection, executionTime: Date().timeIntervalSince(startedAt))
AppCommands.shared.refreshData.send(DataRefreshRequest(connectionId: connection.id, scope: scope))
}

static func drop(connection: DatabaseConnection, tableName: String, name: String) async throws {
static func drop(scope: DatabaseScope, connection: DatabaseConnection, tableName: String, name: String) async throws {
guard let driver = DatabaseManager.shared.driver(for: connection.id) else {
throw TriggerEditingError.notConnected
}
Expand All @@ -118,9 +119,23 @@ enum TriggerEditing {
}

let startedAt = Date()
_ = try await driver.execute(query: dropSQL)
await recordHistory(dropSQL, connection: connection, executionTime: Date().timeIntervalSince(startedAt))
AppCommands.shared.refreshData.send(DataRefreshRequest(connectionId: connection.id))
try await withSchemaChangeDriver(scope: scope) { driver in
_ = try await driver.execute(query: dropSQL)
}
await recordHistory(dropSQL, scope: scope, connection: connection, executionTime: Date().timeIntervalSince(startedAt))
AppCommands.shared.refreshData.send(DataRefreshRequest(connectionId: connection.id, scope: scope))
}

private static func withSchemaChangeDriver(
scope: DatabaseScope,
_ body: @Sendable @escaping (DatabaseDriver) async throws -> Void
) async throws {
try await DatabaseManager.shared.withScopedDriver(
scope: scope,
route: DatabaseManager.shared.schemaChangeRoute(for: scope),
cancellation: .protectedWrite,
body
)
}

static func runInTransaction(driver: DatabaseDriver, dropSQL: String?, sql: String) async throws {
Expand Down Expand Up @@ -152,13 +167,19 @@ enum TriggerEditing {
}
}

private static func recordHistory(_ sql: String, connection: DatabaseConnection, executionTime: TimeInterval) async {
private static func recordHistory(
_ sql: String,
scope: DatabaseScope,
connection: DatabaseConnection,
executionTime: TimeInterval
) async {
await DatabaseManager.shared.historyRecorder.record(
QueryHistoryRecordRequest(
query: sql,
connectionId: connection.id,
databaseName: DatabaseManager.shared.browseDatabaseName(for: connection),
databaseName: scope.database,
databaseType: connection.type,
schemaName: scope.schema,
source: .structureDDL,
executionTime: executionTime,
rowCount: -1,
Expand Down
8 changes: 8 additions & 0 deletions TablePro/Core/Services/Query/MetadataConnectionPool.swift
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,14 @@ final class MetadataConnectionPool {
}
}

#if DEBUG
/// Seeds a connected driver as the pooled connection for `scope`, so a test can observe
/// what runs on the pool without a plugin to open a real connection.
internal func injectEntry(_ driver: DatabaseDriver, scope: DatabaseScope, workload: Workload = .interactive) {
entries[Key(scope: scope, workload: workload)] = Entry(driver: driver)
}
#endif

private func releaseEntry(_ entry: Entry) {
entry.inFlightCount -= 1
if entry.inFlightCount == 0, entry.closeWhenIdle {
Expand Down
1 change: 1 addition & 0 deletions TablePro/Views/Structure/TableStructureView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,7 @@ struct TableStructureView: View {
case .triggers:
TriggerDetailView(
triggers: triggers,
scope: scope,
connection: connection,
tableName: tableName,
isLoading: !tabData.hasData(.triggers),
Expand Down
17 changes: 14 additions & 3 deletions TablePro/Views/Structure/TriggerDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ private struct TriggerEditorSheetItem: Identifiable {

struct TriggerDetailView: View {
let triggers: [TriggerInfo]
let scope: DatabaseScope
let connection: DatabaseConnection
let tableName: String
let isLoading: Bool
Expand Down Expand Up @@ -122,6 +123,7 @@ struct TriggerDetailView: View {

private func makeEditorSheet(for item: TriggerEditorSheetItem) -> some View {
TriggerEditorView(
scope: scope,
connection: connection,
tableName: tableName,
mode: item.mode,
Expand All @@ -139,8 +141,12 @@ struct TriggerDetailView: View {

private func editTrigger(_ trigger: TriggerInfo) {
Task {
let driver = DatabaseManager.shared.driver(for: connection.id)
let fetched = try? await driver?.fetchTriggerDefinition(name: trigger.name, table: tableName)
let scope = scope
let tableName = tableName
let name = trigger.name
let fetched = try? await DatabaseManager.shared.withMetadataDriver(scope: scope) { driver in
try await driver.fetchTriggerDefinition(name: name, table: tableName)
}
let sql = (fetched ?? nil) ?? trigger.statement
editorSheet = TriggerEditorSheetItem(
mode: .edit(originalName: trigger.name, originalDefinition: trigger.statement),
Expand All @@ -152,7 +158,12 @@ struct TriggerDetailView: View {
private func performDelete(_ trigger: TriggerInfo) {
Task {
do {
try await TriggerEditing.drop(connection: connection, tableName: tableName, name: trigger.name)
try await TriggerEditing.drop(
scope: scope,
connection: connection,
tableName: tableName,
name: trigger.name
)
} catch {
actionError = error.localizedDescription
}
Expand Down
12 changes: 11 additions & 1 deletion TablePro/Views/Structure/TriggerEditorView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ struct TriggerEditorView: View {
case edit(originalName: String, originalDefinition: String)
}

let scope: DatabaseScope
let connection: DatabaseConnection
let tableName: String
let mode: Mode
Expand All @@ -29,7 +30,15 @@ struct TriggerEditorView: View {
@Environment(\.colorScheme) private var colorScheme
@AppStorage("structureCodeFontSize", store: AppStorageEnvironment.shared.defaults) private var fontSize: Double = 13

init(connection: DatabaseConnection, tableName: String, mode: Mode, initialSQL: String, onClose: @escaping () -> Void) {
init(
scope: DatabaseScope,
connection: DatabaseConnection,
tableName: String,
mode: Mode,
initialSQL: String,
onClose: @escaping () -> Void
) {
self.scope = scope
self.connection = connection
self.tableName = tableName
self.mode = mode
Expand Down Expand Up @@ -104,6 +113,7 @@ struct TriggerEditorView: View {
defer { isApplying = false }
do {
try await TriggerEditing.apply(
scope: scope,
connection: connection,
tableName: tableName,
sql: sql,
Expand Down
Loading
Loading