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

### Fixed

- Saving a table structure change with more than one connection open no longer applies the change to a different connection or jumps the view back to it. The save now runs against the connection, database, and schema the edited table belongs to, and stops with an error instead of writing if it cannot reach them. (#2015)
- Saving on a MySQL or MariaDB server that starts sessions read-only no longer fails with "Cannot execute statement in a READ ONLY transaction". TablePro marks a transaction read-write before it writes instead of inheriting the server default. Same for PostgreSQL, CockroachDB, and Redshift. (#2009)
- Changing Safe Mode in the connection form now applies to an open connection instead of waiting for a reconnect. (#2009)
- A read-only error now says whether the database server or Safe Mode refused the write. (#2009)
Expand Down
4 changes: 2 additions & 2 deletions TablePro/Core/Database/DatabaseManager+EnsureConnected.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,8 @@ extension DatabaseManager {
}
}
removeSessionEntry(for: connectionId)
if currentSessionId == connectionId {
currentSessionId = nil
if lastActiveSessionId == connectionId {
lastActiveSessionId = nil
}
}
}
Expand Down
6 changes: 0 additions & 6 deletions TablePro/Core/Database/DatabaseManager+Health.swift
Original file line number Diff line number Diff line change
Expand Up @@ -227,12 +227,6 @@ extension DatabaseManager {
}
}

/// Reconnect the current session (called from toolbar Reconnect button)
func reconnectCurrentSession() async {
guard let sessionId = currentSessionId else { return }
await reconnectSession(sessionId)
}

/// Reconnect a specific session by ID
func reconnectSession(_ sessionId: UUID) async {
guard let session = activeSessions[sessionId] else { return }
Expand Down
35 changes: 0 additions & 35 deletions TablePro/Core/Database/DatabaseManager+Queries.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,41 +33,6 @@ extension DatabaseManager {
return try await operation()
}

/// Execute a query on the current session
func execute(query: String) async throws -> QueryResult {
guard let sessionId = currentSessionId, let driver = activeDriver else {
throw DatabaseError.notConnected
}

let result = try await trackOperation(sessionId: sessionId) {
try await driver.execute(query: query)
}
MacAnalyticsProvider.shared.markFirstQueryExecuted()
return result
}

/// Fetch tables from the current session
func fetchTables() async throws -> [TableInfo] {
guard let sessionId = currentSessionId, let driver = activeDriver else {
throw DatabaseError.notConnected
}

return try await trackOperation(sessionId: sessionId) {
try await driver.fetchTables()
}
}

/// Fetch columns for a table from the current session
func fetchColumns(table: String) async throws -> [ColumnInfo] {
guard let sessionId = currentSessionId, let driver = activeDriver else {
throw DatabaseError.notConnected
}

return try await trackOperation(sessionId: sessionId) {
try await driver.fetchColumns(table: table)
}
}

/// Test a connection without keeping it open
func testConnection(
_ connection: DatabaseConnection,
Expand Down
78 changes: 60 additions & 18 deletions TablePro/Core/Database/DatabaseManager+Schema.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,35 +13,29 @@ import TableProPluginKit
// MARK: - Schema Changes

extension DatabaseManager {
/// Execute schema changes (ALTER TABLE, CREATE INDEX, etc.) in a transaction
func executeSchemaChanges(
tableName: String,
changes: [SchemaChange],
databaseType: DatabaseType
) async throws {
guard let sessionId = currentSessionId else {
throw DatabaseError.notConnected
}
try await executeSchemaChanges(
tableName: tableName,
changes: changes,
databaseType: databaseType,
connectionId: sessionId
)
}

/// Execute schema changes using an explicit connection ID (session-scoped)
/// Execute schema changes (ALTER TABLE, CREATE INDEX, etc.) in a transaction.
/// The connection, database, and schema all come from the caller's own tab, never
/// from ambient session state that another window or tab can move.
func executeSchemaChanges(
tableName: String,
changes: [SchemaChange],
databaseType: DatabaseType,
databaseName: String,
schemaName: String?,
connectionId: UUID
) async throws {
guard let driver = driver(for: connectionId) else {
throw DatabaseError.notConnected
}

try await trackOperation(sessionId: connectionId) {
try await pinDatabaseBeforeSchemaChange(
databaseName: databaseName,
schemaName: schemaName,
databaseType: databaseType,
connectionId: connectionId
)

// For PostgreSQL PK modification, query the actual constraint name
let pkConstraintName = await fetchPrimaryKeyConstraintName(
tableName: tableName,
Expand Down Expand Up @@ -126,6 +120,54 @@ extension DatabaseManager {
}
}

/// Point the shared session driver at the database and schema the edited table
/// belongs to. Sibling tabs on the same connection move that driver, so a save must
/// re-pin before it writes. A failed pin aborts the save: a misdirected DDL is
/// silent and often irreversible, unlike a misdirected read.
///
/// Switching database on a schema-grouped engine drops the driver back to the engine's
/// default schema, and those drivers qualify their DDL with whatever schema they are
/// currently on, so the schema has to be restored before any statement is generated.
private func pinDatabaseBeforeSchemaChange(
databaseName: String,
schemaName: String?,
databaseType: DatabaseType,
connectionId: UUID
) async throws {
guard !databaseName.isEmpty,
!pluginManager.requiresReconnectForDatabaseSwitch(for: databaseType),
databaseName != activeSessions[connectionId]?.activeDatabase
else {
return
}

let targetSchema = resolvedSchemaName(schemaName, for: connectionId)

do {
try await switchDatabase(to: databaseName, for: connectionId, persist: false)
try await restoreSchemaAfterDatabasePin(targetSchema, for: connectionId)
} catch {
throw DatabaseError.queryFailed(
String(
format: String(localized: "Could not switch to database %@ before applying schema changes: %@"),
databaseName,
error.localizedDescription
)
)
}
}

private func restoreSchemaAfterDatabasePin(_ targetSchema: String?, for connectionId: UUID) async throws {
guard let targetSchema, !targetSchema.isEmpty,
let schemaDriver = driver(for: connectionId) as? SchemaSwitchable,
schemaDriver.currentSchema != targetSchema
else {
return
}

try await switchSchema(to: targetSchema, for: connectionId)
}

/// Query the actual primary key constraint name for PostgreSQL.
/// Returns nil if the database is not PostgreSQL, no PK modification is pending,
/// or the query fails (caller falls back to `{table}_pkey` convention).
Expand Down
12 changes: 6 additions & 6 deletions TablePro/Core/Database/DatabaseManager+Sessions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ extension DatabaseManager {
session.status = .connecting
setSession(session, for: connection.id)
}
currentSessionId = connection.id
lastActiveSessionId = connection.id

let effectiveConnection: DatabaseConnection
do {
Expand Down Expand Up @@ -196,8 +196,8 @@ extension DatabaseManager {
internal func finalizeConnectionFailure(for connectionId: UUID, cancelled: Bool) {
guard !cancelled else { return }
removeSessionEntry(for: connectionId)
if currentSessionId == connectionId {
currentSessionId = activeSessions.keys.first
if lastActiveSessionId == connectionId {
lastActiveSessionId = activeSessions.keys.first
}
}

Expand Down Expand Up @@ -330,7 +330,7 @@ extension DatabaseManager {

func switchToSession(_ sessionId: UUID) {
guard activeSessions[sessionId] != nil else { return }
currentSessionId = sessionId
lastActiveSessionId = sessionId
updateSession(sessionId) { session in
session.markActive()
}
Expand Down Expand Up @@ -382,11 +382,11 @@ extension DatabaseManager {
SharedSidebarState.removeConnection(sessionId)
SidebarViewModel.removeConnection(sessionId)

if currentSessionId == sessionId {
if lastActiveSessionId == sessionId {
if let nextSessionId = activeSessions.keys.first {
switchToSession(nextSessionId)
} else {
currentSessionId = nil
lastActiveSessionId = nil
}
}
lifecycleLogger.info(
Expand Down
25 changes: 10 additions & 15 deletions TablePro/Core/Database/DatabaseManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,13 @@ final class DatabaseManager {
/// counter to avoid cross-connection re-renders.
internal(set) var connectionStatusVersions: [UUID: Int] = [:]

/// Currently selected session ID (displayed in UI)
internal var currentSessionId: UUID?
/// Best-effort "most recently activated" connection. Window focus never re-anchors it,
/// so it is only valid for UI highlighting (which connection the switcher marks active)
/// and as a fallback for entry points that have no window of their own, such as a new
/// contentless window or a file opened from Finder. Never resolve the target of an
/// operation through it: read the connection id from the window or tab that owns the
/// operation instead.
internal var lastActiveSessionId: UUID?

/// Health monitors for active connections (MySQL/PostgreSQL only)
@ObservationIgnored internal var healthMonitors: [UUID: ConnectionHealthMonitor] = [:]
Expand All @@ -68,17 +73,12 @@ final class DatabaseManager {
/// before touching shared session state and discards its driver when it lost.
@ObservationIgnored internal var connectionAttempts = ConnectionAttemptRegistry()

/// Current session (computed from currentSessionId)
var currentSession: ConnectionSession? {
guard let sessionId = currentSessionId else { return nil }
/// Session for `lastActiveSessionId`, subject to the same caveats.
var lastActiveSession: ConnectionSession? {
guard let sessionId = lastActiveSessionId else { return nil }
return activeSessions[sessionId]
}

/// Current driver (for convenience)
var activeDriver: DatabaseDriver? {
currentSession?.driver
}

/// Resolve the driver for a specific connection (session-scoped, no global state)
func driver(for connectionId: UUID) -> DatabaseDriver? {
activeSessions[connectionId]?.driver
Expand Down Expand Up @@ -109,11 +109,6 @@ final class DatabaseManager {
return sessionSchema
}

/// Current connection status
var status: ConnectionStatus {
currentSession?.status ?? .disconnected
}

internal init(
connectionStorage: ConnectionStorage = .shared,
appSettingsStorage: AppSettingsStorage = .shared,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi
var resolvedSession: ConnectionSession?
if let connectionId = payload?.connectionId {
resolvedSession = DatabaseManager.shared.activeSessions[connectionId]
} else if let currentId = DatabaseManager.shared.currentSessionId {
} else if let currentId = DatabaseManager.shared.lastActiveSessionId {
resolvedSession = DatabaseManager.shared.activeSessions[currentId]
}
self.currentSession = resolvedSession
Expand Down Expand Up @@ -243,7 +243,7 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi
guard closingSessionId == nil else { return }

let sessions = DatabaseManager.shared.activeSessions
let connectionId = payload?.connectionId ?? currentSession?.id ?? DatabaseManager.shared.currentSessionId
let connectionId = payload?.connectionId ?? currentSession?.id ?? DatabaseManager.shared.lastActiveSessionId

guard let sid = connectionId else {
if currentSession != nil { currentSession = nil }
Expand Down
2 changes: 1 addition & 1 deletion TablePro/Core/Services/Infrastructure/TabRouter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -352,7 +352,7 @@ internal final class TabRouter {
return
}

if let session = DatabaseManager.shared.currentSession {
if let session = DatabaseManager.shared.lastActiveSession {
let content = await Task.detached(priority: .userInitiated) { () -> String? in
try? String(contentsOf: url, encoding: .utf8)
}.value
Expand Down
10 changes: 9 additions & 1 deletion TablePro/Views/Main/Child/MainEditorContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -522,6 +522,12 @@ struct MainEditorContentView: View {
}
}

private func structureDatabaseName(for tab: QueryTab) -> String {
tab.tableContext.databaseName.isEmpty
? coordinator.activeDatabaseName
: tab.tableContext.databaseName
}

@ViewBuilder
private func resultsSection(tab: QueryTab) -> some View {
VStack(spacing: 0) {
Expand All @@ -532,11 +538,13 @@ struct MainEditorContentView: View {
TableStructureView(
tableName: tableName,
connection: connection,
databaseName: structureDatabaseName(for: tab),
schemaName: tab.tableContext.schemaName,
toolbarState: coordinator.toolbarState,
coordinator: coordinator,
selectionState: selectionState
)
.id(tableName)
.id("\(tab.tableContext.databaseName).\(tableName)")
.frame(maxHeight: .infinity)
}
case .json:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,7 @@ extension MainContentCoordinator {
}

Task {
let window = NSApp.keyWindow
let confirmed = await confirmDiscardChanges(action: action, window: window)
let confirmed = await confirmDiscardChanges(action: action, window: contentWindow)
if confirmed {
changeManager.clearChangesAndUndoHistory()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ extension MainContentCoordinator {
}

Task {
let confirmed = await confirmDiscardChanges(action: .refresh, window: NSApp.keyWindow)
let confirmed = await confirmDiscardChanges(action: .refresh, window: contentWindow)
guard confirmed else { return }
onDiscard()
changeManager.clearChangesAndUndoHistory()
Expand Down
5 changes: 4 additions & 1 deletion TablePro/Views/Structure/TableStructureView+Schema.swift
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,10 @@ extension TableStructureView {
try await DatabaseManager.shared.executeSchemaChanges(
tableName: tableName,
changes: changes,
databaseType: connection.type
databaseType: connection.type,
databaseName: databaseName,
schemaName: schemaName,
connectionId: connection.id
)

tabData.markAllStale()
Expand Down
8 changes: 8 additions & 0 deletions TablePro/Views/Structure/TableStructureView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ struct TableStructureView: View {
static let structurePasteboardType = NSPasteboard.PasteboardType("com.TablePro.structure")
let tableName: String
let connection: DatabaseConnection
let databaseName: String
let schemaName: String?
let toolbarState: ConnectionToolbarState
let coordinator: MainContentCoordinator?
let selectionState: GridSelectionState
Expand Down Expand Up @@ -59,12 +61,16 @@ struct TableStructureView: View {
init(
tableName: String,
connection: DatabaseConnection,
databaseName: String,
schemaName: String?,
toolbarState: ConnectionToolbarState,
coordinator: MainContentCoordinator?,
selectionState: GridSelectionState
) {
self.tableName = tableName
self.connection = connection
self.databaseName = databaseName
self.schemaName = schemaName
self.toolbarState = toolbarState
self.coordinator = coordinator
self.selectionState = selectionState
Expand Down Expand Up @@ -477,6 +483,8 @@ struct TableStructureView: View {
username: "root",
type: .mysql
),
databaseName: "test",
schemaName: nil,
toolbarState: ConnectionToolbarState(),
coordinator: nil,
selectionState: GridSelectionState()
Expand Down
2 changes: 1 addition & 1 deletion TablePro/Views/Toolbar/ConnectionSwitcherPopover.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ struct ConnectionSwitcherPopover: View {
}

private var currentSessionId: UUID? {
DatabaseManager.shared.currentSessionId
DatabaseManager.shared.lastActiveSessionId
}

private var sortedSessions: [ConnectionSession] {
Expand Down
Loading
Loading