From 4aafa765a8df8593893b2aad787f67442eaa6455 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 3 Sep 2026 13:04:47 +0700 Subject: [PATCH] fix(coordinator): run structure and trigger edits on a connection of their own instead of the session driver Claude-Session: https://claude.ai/code/session_014THhVdsUjbCboxNLnQB1dR --- CHANGELOG.md | 1 + .../Database/DatabaseManager+Schema.swift | 9 +- .../DatabaseManager+ScopedDriver.swift | 10 ++ TablePro/Core/Database/EnumLabelEditor.swift | 9 +- TablePro/Core/Database/TriggerEditing.swift | 75 ++++++---- .../Query/MetadataConnectionPool.swift | 8 ++ .../Views/Structure/TableStructureView.swift | 1 + .../Views/Structure/TriggerDetailView.swift | 17 ++- .../Views/Structure/TriggerEditorView.swift | 12 +- ...abaseManagerSchemaChangeRoutingTests.swift | 130 ++++++++++++++---- .../Database/TriggerInfoMappingTests.swift | 38 +++++ docs/features/table-structure.mdx | 4 +- 12 files changed, 245 insertions(+), 69 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ff9378f5..f8fb49e32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. - Silent fallback order when foreign keys between the exported tables form a cycle. (#2517) - Foreign keys declared twice in a SQL export on MySQL, SQL Server, DuckDB, Snowflake, CockroachDB and Redshift, and as an unsupported `ALTER TABLE` on SQLite, libSQL and Cloudflare D1. (#2517) - Foreign keys missing from Redshift's reconstructed `CREATE TABLE`. diff --git a/TablePro/Core/Database/DatabaseManager+Schema.swift b/TablePro/Core/Database/DatabaseManager+Schema.swift index 4ecef9024..c614dbcec 100644 --- a/TablePro/Core/Database/DatabaseManager+Schema.swift +++ b/TablePro/Core/Database/DatabaseManager+Schema.swift @@ -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 @@ -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 diff --git a/TablePro/Core/Database/DatabaseManager+ScopedDriver.swift b/TablePro/Core/Database/DatabaseManager+ScopedDriver.swift index cc40c6f44..0dd0a748f 100644 --- a/TablePro/Core/Database/DatabaseManager+ScopedDriver.swift +++ b/TablePro/Core/Database/DatabaseManager+ScopedDriver.swift @@ -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 diff --git a/TablePro/Core/Database/EnumLabelEditor.swift b/TablePro/Core/Database/EnumLabelEditor.swift index fd6ff9d74..df961a12f 100644 --- a/TablePro/Core/Database/EnumLabelEditor.swift +++ b/TablePro/Core/Database/EnumLabelEditor.swift @@ -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) diff --git a/TablePro/Core/Database/TriggerEditing.swift b/TablePro/Core/Database/TriggerEditing.swift index b341807cd..58454d9af 100644 --- a/TablePro/Core/Database/TriggerEditing.swift +++ b/TablePro/Core/Database/TriggerEditing.swift @@ -43,7 +43,11 @@ 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, @@ -51,10 +55,6 @@ enum TriggerEditing { 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, @@ -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 } @@ -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 { @@ -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, diff --git a/TablePro/Core/Services/Query/MetadataConnectionPool.swift b/TablePro/Core/Services/Query/MetadataConnectionPool.swift index 672a08e09..12843d1e2 100644 --- a/TablePro/Core/Services/Query/MetadataConnectionPool.swift +++ b/TablePro/Core/Services/Query/MetadataConnectionPool.swift @@ -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 { diff --git a/TablePro/Views/Structure/TableStructureView.swift b/TablePro/Views/Structure/TableStructureView.swift index d847f8297..412695ee2 100644 --- a/TablePro/Views/Structure/TableStructureView.swift +++ b/TablePro/Views/Structure/TableStructureView.swift @@ -410,6 +410,7 @@ struct TableStructureView: View { case .triggers: TriggerDetailView( triggers: triggers, + scope: scope, connection: connection, tableName: tableName, isLoading: !tabData.hasData(.triggers), diff --git a/TablePro/Views/Structure/TriggerDetailView.swift b/TablePro/Views/Structure/TriggerDetailView.swift index 50da9415e..1b39aae1e 100644 --- a/TablePro/Views/Structure/TriggerDetailView.swift +++ b/TablePro/Views/Structure/TriggerDetailView.swift @@ -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 @@ -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, @@ -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), @@ -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 } diff --git a/TablePro/Views/Structure/TriggerEditorView.swift b/TablePro/Views/Structure/TriggerEditorView.swift index 6225b3a6c..38dfd8bd2 100644 --- a/TablePro/Views/Structure/TriggerEditorView.swift +++ b/TablePro/Views/Structure/TriggerEditorView.swift @@ -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 @@ -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 @@ -104,6 +113,7 @@ struct TriggerEditorView: View { defer { isApplying = false } do { try await TriggerEditing.apply( + scope: scope, connection: connection, tableName: tableName, sql: sql, diff --git a/TableProTests/Core/Database/DatabaseManagerSchemaChangeRoutingTests.swift b/TableProTests/Core/Database/DatabaseManagerSchemaChangeRoutingTests.swift index 302eeace2..7425ee777 100644 --- a/TableProTests/Core/Database/DatabaseManagerSchemaChangeRoutingTests.swift +++ b/TableProTests/Core/Database/DatabaseManagerSchemaChangeRoutingTests.swift @@ -5,7 +5,9 @@ // Pins the fix for #2015 and #2026: a table structure save runs its DDL on the scope // the editing tab owns, and moving the shared driver there is a mechanical detail no // UI reads. The save must not drag the sidebar, the toolbar or the saved default -// database onto the edited tab's database. +// database onto the edited tab's database. And it runs on a connection of its own +// wherever the engine can pool one, so its BEGIN never joins a transaction a query tab +// left open on the session driver. // import Combine @@ -82,6 +84,34 @@ private final class SchemaRoutingDriver: SchemaRoutingBaseDriver, PluginDatabase @Suite("DatabaseManager schema change routing", .serialized) @MainActor struct DatabaseManagerSchemaChangeRoutingTests { + /// An engine that changes database on its live connection but cannot pool a second one, + /// so a save has nowhere to run but the session driver and has to pin it. + private static let singleConnectionTypeId = "SchemaRoutingSingleConnection" + + private static var singleConnectionType: DatabaseType { + registerSingleConnectionTypeIfNeeded() + return DatabaseType(rawValue: singleConnectionTypeId) + } + + private static func registerSingleConnectionTypeIfNeeded() { + guard PluginMetadataRegistry.shared.snapshot(forRegisteredTypeId: singleConnectionTypeId) == nil else { + return + } + var capabilities = PluginMetadataSnapshot.CapabilityFlags.defaults + capabilities.supportsConnectionPooling = false + let snapshot = PluginMetadataSnapshot( + displayName: singleConnectionTypeId, iconName: "cylinder", defaultPort: 1_234, + requiresAuthentication: true, supportsForeignKeys: true, supportsSchemaEditing: true, + isDownloadable: false, primaryUrlScheme: "schemaroutingsingle", parameterStyle: .questionMark, + navigationModel: .standard, explainVariants: [], pathFieldRole: .database, + supportsHealthMonitor: false, urlSchemes: ["schemaroutingsingle"], postConnectActions: [], + brandColorHex: "#000000", queryLanguageName: "SQL", editorLanguage: .sql, + connectionMode: .network, supportsDatabaseSwitching: true, + capabilities: capabilities, schema: .defaults, editor: .defaults, connection: .defaults + ) + PluginMetadataRegistry.shared.register(snapshot: snapshot, forTypeId: singleConnectionTypeId) + } + private static func makeAddColumnChange(named name: String = "notes") -> SchemaChange { var column = EditableColumnDefinition.placeholder() column.name = name @@ -108,6 +138,26 @@ struct DatabaseManagerSchemaChangeRoutingTests { return (connection, pluginDriver) } + /// Stands in for the connection the pool would open on the scope, which the pool puts on + /// the scope's database and schema before handing it out. + private static func seedPooledDriver( + _ connection: DatabaseConnection, + scope: DatabaseScope + ) async throws -> SchemaRoutingDriver { + let pluginDriver = SchemaRoutingDriver(currentSchema: scope.schema) + let adapter = PluginDriverAdapter(connection: connection, pluginDriver: pluginDriver) + try await adapter.connect() + MetadataConnectionPool.shared.injectEntry(adapter, scope: scope) + return pluginDriver + } + + private static func tearDown(_ connections: DatabaseConnection...) { + for connection in connections { + MetadataConnectionPool.shared.closeAll(connectionId: connection.id) + DatabaseManager.shared.removeSession(for: connection.id) + } + } + private static func makeScope( _ connection: DatabaseConnection, database: String, @@ -116,18 +166,33 @@ struct DatabaseManagerSchemaChangeRoutingTests { DatabaseScope(connectionId: connection.id, database: database, schema: schema) } + @Test("A save takes a pooled connection wherever the engine can open one") + func schemaChangeRouteIsPooledWhereverTheEngineCanPool() throws { + let (pooling, _) = Self.makeSession(savedDatabase: "orders") + let (single, _) = Self.makeSession(type: Self.singleConnectionType, savedDatabase: "orders") + defer { Self.tearDown(pooling, single) } + + let poolingScope = try #require(Self.makeScope(pooling, database: "orders")) + let singleScope = try #require(Self.makeScope(single, database: "orders")) + let serverScope = try #require(Self.makeScope(pooling, database: "")) + + #expect(DatabaseManager.shared.schemaChangeRoute(for: poolingScope) == .pooled) + #expect(DatabaseManager.shared.schemaChangeRoute(for: singleScope) == .sessionDriver) + #expect(DatabaseManager.shared.schemaChangeRoute(for: serverScope) == .sessionDriver) + } + @Test("Schema changes run on the requested connection, not the last activated one") func schemaChangeUsesRequestedConnection() async throws { let (connectionA, driverA) = Self.makeSession(savedDatabase: "alpha") let (connectionB, driverB) = Self.makeSession(savedDatabase: "beta") DatabaseManager.shared.lastActiveSessionId = connectionA.id defer { - DatabaseManager.shared.removeSession(for: connectionA.id) - DatabaseManager.shared.removeSession(for: connectionB.id) + Self.tearDown(connectionA, connectionB) DatabaseManager.shared.lastActiveSessionId = nil } let scope = try #require(Self.makeScope(connectionB, database: "beta")) + let pooledB = try await Self.seedPooledDriver(connectionB, scope: scope) try await DatabaseManager.shared.executeSchemaChanges( tableName: "orders", changes: [Self.makeAddColumnChange()], @@ -135,20 +200,25 @@ struct DatabaseManagerSchemaChangeRoutingTests { scope: scope ) - #expect(driverB.executedQueries.count == 1) - #expect(driverB.executedQueries.first?.contains("ADD COLUMN") == true) + #expect(pooledB.executedQueries.count == 1) + #expect(pooledB.executedQueries.first?.contains("ADD COLUMN") == true) #expect(driverA.executedQueries.isEmpty) + #expect(driverB.executedQueries.isEmpty) } - @Test("A save runs on the tab's database without moving the browse cursor") - func schemaChangeRunsOnTheTabsDatabaseWithoutMovingTheBrowseCursor() async throws { + /// The session driver holds whatever transaction a query tab left open. A save that ran + /// there wrapped its DDL in a BEGIN that joined that transaction and a COMMIT that took the + /// tab's uncommitted work with it, or a ROLLBACK that threw it away. + @Test("A save runs on the tab's database on its own connection, leaving the session driver alone") + func schemaChangeRunsOnItsOwnConnectionWithoutMovingTheBrowseCursor() async throws { let (connection, driver) = Self.makeSession( savedDatabase: "analytics", browseDatabase: "inventory" ) - defer { DatabaseManager.shared.removeSession(for: connection.id) } + defer { Self.tearDown(connection) } let scope = try #require(Self.makeScope(connection, database: "orders")) + let pooled = try await Self.seedPooledDriver(connection, scope: scope) try await DatabaseManager.shared.executeSchemaChanges( tableName: "orders", changes: [Self.makeAddColumnChange()], @@ -156,25 +226,25 @@ struct DatabaseManagerSchemaChangeRoutingTests { scope: scope ) - #expect(driver.switchedDatabases.allSatisfy { $0 == "orders" }) - #expect(!driver.switchedDatabases.isEmpty) - #expect(driver.executedQueries.count == 1) + #expect(pooled.executedQueries.count == 1) + #expect(driver.executedQueries.isEmpty) + #expect(driver.switchedDatabases.isEmpty) let session = DatabaseManager.shared.session(for: connection.id) #expect(session?.browseDatabase == "inventory") #expect(session?.connection.database == "analytics") } - @Test("A save always pins its target database because nothing tracks where the driver is") - func schemaChangeAlwaysPinsItsTargetDatabase() async throws { - let (connection, driver) = Self.makeSession(savedDatabase: "orders") - defer { DatabaseManager.shared.removeSession(for: connection.id) } + @Test("An engine with one connection pins the session driver to the target database") + func singleConnectionEnginePinsItsTargetDatabase() async throws { + let (connection, driver) = Self.makeSession(type: Self.singleConnectionType, savedDatabase: "orders") + defer { Self.tearDown(connection) } let scope = try #require(Self.makeScope(connection, database: "orders")) try await DatabaseManager.shared.executeSchemaChanges( tableName: "orders", changes: [Self.makeAddColumnChange()], - databaseType: .mysql, + databaseType: Self.singleConnectionType, scope: scope ) @@ -186,18 +256,19 @@ struct DatabaseManagerSchemaChangeRoutingTests { @Test("A failed database pin aborts the save before any DDL runs") func failedDatabasePinAbortsSave() async throws { let (connection, driver) = Self.makeSession( + type: Self.singleConnectionType, savedDatabase: "orders", browseDatabase: "inventory" ) driver.switchDatabaseError = DatabaseError.queryFailed("unknown database") - defer { DatabaseManager.shared.removeSession(for: connection.id) } + defer { Self.tearDown(connection) } let scope = try #require(Self.makeScope(connection, database: "orders")) await #expect(throws: DatabaseError.self) { try await DatabaseManager.shared.executeSchemaChanges( tableName: "orders", changes: [Self.makeAddColumnChange()], - databaseType: .mysql, + databaseType: Self.singleConnectionType, scope: scope ) } @@ -211,9 +282,10 @@ struct DatabaseManagerSchemaChangeRoutingTests { type: .postgresql, savedDatabase: "orders" ) - defer { DatabaseManager.shared.removeSession(for: connection.id) } + defer { Self.tearDown(connection) } let scope = try #require(Self.makeScope(connection, database: "orders")) + let pooled = try await Self.seedPooledDriver(connection, scope: scope) try await DatabaseManager.shared.executeSchemaChanges( tableName: "orders", changes: [Self.makeAddColumnChange()], @@ -222,20 +294,22 @@ struct DatabaseManagerSchemaChangeRoutingTests { ) #expect(driver.switchedDatabases.isEmpty) - #expect(driver.executedQueries.count == 1) + #expect(driver.executedQueries.isEmpty) + #expect(pooled.executedQueries.count == 1) } - @Test("A schema-grouped engine keeps the edited table's schema across the database pin") + @Test("A schema-grouped engine qualifies the DDL with the edited table's schema") func schemaGroupedEngineKeepsTableSchema() async throws { let (connection, driver) = Self.makeSession( type: .mssql, savedDatabase: "orders", browseDatabase: "inventory", - browseSchema: "sales" + browseSchema: "dbo" ) - defer { DatabaseManager.shared.removeSession(for: connection.id) } + defer { Self.tearDown(connection) } let scope = try #require(Self.makeScope(connection, database: "orders", schema: "sales")) + let pooled = try await Self.seedPooledDriver(connection, scope: scope) try await DatabaseManager.shared.executeSchemaChanges( tableName: "orders", changes: [Self.makeAddColumnChange()], @@ -243,10 +317,9 @@ struct DatabaseManagerSchemaChangeRoutingTests { scope: scope ) - #expect(!driver.switchedDatabases.isEmpty) - #expect(driver.switchedDatabases.allSatisfy { $0 == "orders" }) - #expect(driver.currentSchema == "sales") - #expect(driver.executedQueries.first?.contains("`sales`.`orders`") == true) + #expect(pooled.executedQueries.first?.contains("`sales`.`orders`") == true) + #expect(driver.currentSchema == "dbo") + #expect(driver.executedQueries.isEmpty) } @Test("A save broadcasts a refresh scoped to the edited tab, not to the browse cursor") @@ -255,7 +328,7 @@ struct DatabaseManagerSchemaChangeRoutingTests { savedDatabase: "analytics", browseDatabase: "inventory" ) - defer { DatabaseManager.shared.removeSession(for: connection.id) } + defer { Self.tearDown(connection) } let recorder = RefreshRequestRecorder() let cancellable = AppCommands.shared.refreshData.sink { request in @@ -264,6 +337,7 @@ struct DatabaseManagerSchemaChangeRoutingTests { defer { cancellable.cancel() } let scope = try #require(Self.makeScope(connection, database: "orders")) + _ = try await Self.seedPooledDriver(connection, scope: scope) try await DatabaseManager.shared.executeSchemaChanges( tableName: "orders", changes: [Self.makeAddColumnChange()], diff --git a/TableProTests/Core/Database/TriggerInfoMappingTests.swift b/TableProTests/Core/Database/TriggerInfoMappingTests.swift index 2c30044a1..8fec17011 100644 --- a/TableProTests/Core/Database/TriggerInfoMappingTests.swift +++ b/TableProTests/Core/Database/TriggerInfoMappingTests.swift @@ -241,4 +241,42 @@ struct TriggerApplyExecutionTests { } #expect(stub.executedQueries == ["DROP TRIGGER t", "CREATE TRIGGER t", "RESTORE t"]) } + + /// The session driver holds whatever transaction a query tab left open, so a trigger edit + /// with a BEGIN of its own ran inside it and committed or rolled back the tab's work. + @Test("Apply and drop run on the pooled connection and leave the session driver alone") + func applyAndDropRunOnThePooledConnection() async throws { + let connection = TestFixtures.makeConnection(database: "app", type: .postgresql) + let sessionStub = StubTriggerDriver() + sessionStub.dropToReturn = "DROP TRIGGER t" + let session = ConnectionSession( + connection: connection, + driver: PluginDriverAdapter(connection: connection, pluginDriver: sessionStub) + ) + DatabaseManager.shared.injectSession(session, for: connection.id) + let scope = DatabaseScope(connectionId: connection.id, database: "app", schema: "public") + let pooledStub = StubTriggerDriver() + pooledStub.transactionalDDL = true + let pooledAdapter = PluginDriverAdapter(connection: connection, pluginDriver: pooledStub) + try await pooledAdapter.connect() + MetadataConnectionPool.shared.injectEntry(pooledAdapter, scope: scope) + defer { + MetadataConnectionPool.shared.closeAll(connectionId: connection.id) + DatabaseManager.shared.removeSession(for: connection.id) + } + + try await TriggerEditing.apply( + scope: scope, + connection: connection, + tableName: "orders", + sql: "CREATE TRIGGER t", + isEdit: false, + originalName: nil, + originalDefinition: nil + ) + try await TriggerEditing.drop(scope: scope, connection: connection, tableName: "orders", name: "t") + + #expect(pooledStub.executedQueries == ["BEGIN", "CREATE TRIGGER t", "COMMIT", "DROP TRIGGER t"]) + #expect(sessionStub.executedQueries.isEmpty) + } } diff --git a/docs/features/table-structure.mdx b/docs/features/table-structure.mdx index abd9646b4..dcc288f59 100644 --- a/docs/features/table-structure.mdx +++ b/docs/features/table-structure.mdx @@ -116,13 +116,15 @@ The queue outlives everything short of an explicit discard: closing the tab, clo Schema change preview with ALTER TABLE statements +A save runs on a connection of its own, so it never joins a transaction you have left open in a query tab. + ### When one statement fails A save is often several statements, run in order. Engines with transactional DDL roll the whole set back; MySQL, MariaDB, and Oracle commit each one as it runs, so everything before the failure has landed while the queue still holds all of it. An **Error Applying Changes** sheet reports what the server said: refresh before saving again, or the second save replays work the server already did. ## Triggers tab -Lists **Name**, **Timing** (BEFORE, AFTER, INSTEAD OF), **Event** (INSERT, UPDATE, DELETE), and **Enabled** where the engine reports it. Select one to read its `CREATE TRIGGER` statement from the catalog, with **Copy** and **Open in Editor**. **New Trigger**, **Edit**, and **Delete** sit in the action bar; the editor opens the trigger's real DDL, including the trigger function on PostgreSQL. +Lists **Name**, **Timing** (BEFORE, AFTER, INSTEAD OF), **Event** (INSERT, UPDATE, DELETE), and **Enabled** where the engine reports it. Select one to read its `CREATE TRIGGER` statement from the catalog, with **Copy** and **Open in Editor**. **New Trigger**, **Edit**, and **Delete** sit in the action bar; the editor opens the trigger's real DDL, including the trigger function on PostgreSQL. Trigger changes run on their own connection, like a structure save. Triggers are available for MySQL, MariaDB, PostgreSQL, SQLite, SQL Server, Oracle, libSQL, and Cloudflare D1; the tab is hidden elsewhere. Oracle does not return the trigger body, so the viewer and the editor start from the trigger header alone.