diff --git a/CHANGELOG.md b/CHANGELOG.md index c489fb97a..845b79cd1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -74,6 +74,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Safe mode level changes in the toolbar persist as the connection default across reconnects. - Toolbar customizations persist after closing and reopening a session window. (#1455) - Pasting rows with commas in a cell keeps each value in its own column and preserves NULL vs the literal text "NULL". - BigQuery: switching to another table loads its data immediately instead of leaving the grid empty. diff --git a/TablePro/Core/Database/DatabaseManager+Sessions.swift b/TablePro/Core/Database/DatabaseManager+Sessions.swift index 2fc001e29..2bf7c963c 100644 --- a/TablePro/Core/Database/DatabaseManager+Sessions.swift +++ b/TablePro/Core/Database/DatabaseManager+Sessions.swift @@ -14,7 +14,9 @@ import TableProPluginKit // MARK: - Session Management extension DatabaseManager { - func connectToSession(_ connection: DatabaseConnection) async throws { + func connectToSession(_ requestedConnection: DatabaseConnection) async throws { + let connection = resolvedConnectionDefinition(for: requestedConnection) + if let existing = activeSessions[connection.id], existing.driver != nil { switchToSession(connection.id) return @@ -178,6 +180,13 @@ extension DatabaseManager { } } + internal func resolvedConnectionDefinition(for connection: DatabaseConnection) -> DatabaseConnection { + guard let stored = connectionStorage.loadConnection(id: connection.id) else { return connection } + var resolved = connection + resolved.safeModeLevel = stored.safeModeLevel + return resolved + } + internal func finalizeConnectionFailure(for connectionId: UUID, cancelled: Bool) { guard !cancelled else { return } removeSessionEntry(for: connectionId) @@ -391,9 +400,12 @@ extension DatabaseManager { } func setSafeModeLevel(_ level: SafeModeLevel, for connectionId: UUID) { - guard var session = activeSessions[connectionId], session.safeModeLevel != level else { return } + guard var session = activeSessions[connectionId] else { return } + guard session.safeModeLevel != level || session.connection.safeModeLevel != level else { return } session.safeModeLevel = level + session.connection.safeModeLevel = level setSession(session, for: connectionId) + _ = connectionStorage.updateSafeModeLevel(level, for: connectionId) } internal func setSession(_ session: ConnectionSession, for connectionId: UUID) { diff --git a/TablePro/Core/Storage/ConnectionStorage.swift b/TablePro/Core/Storage/ConnectionStorage.swift index 0cd305a9b..778c5023e 100644 --- a/TablePro/Core/Storage/ConnectionStorage.swift +++ b/TablePro/Core/Storage/ConnectionStorage.swift @@ -107,6 +107,10 @@ final class ConnectionStorage { } } + func loadConnection(id: UUID) -> DatabaseConnection? { + loadConnections().first { $0.id == id } + } + /// Save all connections. Returns `true` if persisted, `false` if encoding or /// the atomic write failed. Callers that mutate dependent state (sync tracker, /// keychain entries) MUST check the return value and abort on `false`. @@ -196,6 +200,34 @@ final class ConnectionStorage { return true } + @discardableResult + func updateSafeModeLevel(_ level: SafeModeLevel, for connectionId: UUID) -> Bool { + var connections = loadConnections() + guard let index = connections.firstIndex(where: { $0.id == connectionId }) else { + Self.logger.notice( + "Skipped updateSafeModeLevel: connection not found for \(connectionId, privacy: .public)" + ) + return false + } + + guard connections[index].safeModeLevel != level else { return true } + + connections[index].safeModeLevel = level + guard saveConnections(connections) else { + Self.logger.error( + "Aborted updateSafeModeLevel: persistence failed for \(connectionId, privacy: .public)" + ) + return false + } + + let updatedConnection = connections[index] + if !updatedConnection.localOnly && !updatedConnection.isSample { + syncTracker.markDirty(.connection, id: updatedConnection.id.uuidString) + } + + return true + } + /// Delete a connection func deleteConnection(_ connection: DatabaseConnection) { var connections = loadConnections() diff --git a/TableProTests/Core/Storage/ConnectionStoragePersistenceTests.swift b/TableProTests/Core/Storage/ConnectionStoragePersistenceTests.swift index 93b537536..fba62c8fe 100644 --- a/TableProTests/Core/Storage/ConnectionStoragePersistenceTests.swift +++ b/TableProTests/Core/Storage/ConnectionStoragePersistenceTests.swift @@ -4,9 +4,9 @@ // import Foundation +@testable import TablePro import TableProPluginKit import Testing -@testable import TablePro @Suite("ConnectionStorage Persistence") @MainActor @@ -26,8 +26,12 @@ struct ConnectionStoragePersistenceTests { withIntermediateDirectories: true ) let suiteName = "com.TablePro.tests.ConnectionStorage.\(unique)" - self.defaults = UserDefaults(suiteName: suiteName)! - let syncDefaults = UserDefaults(suiteName: "com.TablePro.tests.Sync.\(unique)")! + guard let defaults = UserDefaults(suiteName: suiteName), + let syncDefaults = UserDefaults(suiteName: "com.TablePro.tests.Sync.\(unique)") + else { + fatalError("Failed to create isolated test user defaults") + } + self.defaults = defaults let metadata = SyncMetadataStorage(userDefaults: syncDefaults) self.syncTracker = SyncChangeTracker(metadataStorage: metadata) self.storage = ConnectionStorage( @@ -49,12 +53,34 @@ struct ConnectionStoragePersistenceTests { #expect(reloaded.contains { $0.id == connection.id }) } + @Test("updateSafeModeLevel writes the new level through to disk") + func updateSafeModeLevelWritesThrough() { + let connection = DatabaseConnection( + name: "Write Through", + host: "127.0.0.1", + port: 3_306, + type: .mysql, + safeModeLevel: .silent + ) + + storage.addConnection(connection) + storage.invalidateCache() + #expect(storage.loadConnections().first { $0.id == connection.id }?.safeModeLevel == .silent) + + let updated = storage.updateSafeModeLevel(.readOnly, for: connection.id) + #expect(updated) + + storage.invalidateCache() + let reloaded = storage.loadConnections().first { $0.id == connection.id } + #expect(reloaded?.safeModeLevel == .readOnly) + } + @Test("round-trip save and load preserves connections") func roundTripSaveLoad() { let connection = DatabaseConnection( name: "Round Trip Test", host: "127.0.0.1", - port: 5432, + port: 5_432, type: .postgresql ) diff --git a/TableProTests/Core/Storage/SafeModeMigrationTests.swift b/TableProTests/Core/Storage/SafeModeMigrationTests.swift index 450d52e8d..efabb398f 100644 --- a/TableProTests/Core/Storage/SafeModeMigrationTests.swift +++ b/TableProTests/Core/Storage/SafeModeMigrationTests.swift @@ -6,15 +6,16 @@ // import Foundation +@testable import TablePro import TableProPluginKit import Testing -@testable import TablePro @Suite("SafeModeMigration") @MainActor struct SafeModeMigrationTests { private let storage: ConnectionStorage private let defaults: UserDefaults + private let tracker: SyncChangeTracker init() { let unique = UUID().uuidString @@ -26,8 +27,15 @@ struct SafeModeMigrationTests { withIntermediateDirectories: true ) let suiteName = "com.TablePro.tests.ConnectionStorage.\(unique)" - self.defaults = UserDefaults(suiteName: suiteName)! - self.storage = ConnectionStorage(fileURL: fileURL, userDefaults: defaults) + guard let defaults = UserDefaults(suiteName: suiteName), + let syncDefaults = UserDefaults(suiteName: "com.TablePro.tests.Sync.\(unique)") + else { + fatalError("Failed to create isolated test user defaults") + } + self.defaults = defaults + let metadata = SyncMetadataStorage(userDefaults: syncDefaults) + self.tracker = SyncChangeTracker(metadataStorage: metadata) + self.storage = ConnectionStorage(fileURL: fileURL, userDefaults: defaults, syncTracker: tracker) } // MARK: - Round-Trip Through ConnectionStorage API @@ -36,7 +44,7 @@ struct SafeModeMigrationTests { func roundTripSilent() throws { let id = UUID() let connection = DatabaseConnection( - id: id, name: "Silent Test", host: "127.0.0.1", port: 3306, + id: id, name: "Silent Test", host: "127.0.0.1", port: 3_306, database: "test", username: "root", type: .mysql, safeModeLevel: .silent ) @@ -51,7 +59,7 @@ struct SafeModeMigrationTests { func roundTripAlert() throws { let id = UUID() let connection = DatabaseConnection( - id: id, name: "Alert Test", host: "127.0.0.1", port: 5432, + id: id, name: "Alert Test", host: "127.0.0.1", port: 5_432, database: "test", username: "postgres", type: .postgresql, safeModeLevel: .alert ) @@ -66,7 +74,7 @@ struct SafeModeMigrationTests { func roundTripAlertFull() throws { let id = UUID() let connection = DatabaseConnection( - id: id, name: "AlertFull Test", host: "127.0.0.1", port: 3306, + id: id, name: "AlertFull Test", host: "127.0.0.1", port: 3_306, database: "test", username: "root", type: .mysql, safeModeLevel: .alertFull ) @@ -81,7 +89,7 @@ struct SafeModeMigrationTests { func roundTripSafeMode() throws { let id = UUID() let connection = DatabaseConnection( - id: id, name: "SafeMode Test", host: "127.0.0.1", port: 3306, + id: id, name: "SafeMode Test", host: "127.0.0.1", port: 3_306, database: "test", username: "root", type: .mysql, safeModeLevel: .safeMode ) @@ -96,7 +104,7 @@ struct SafeModeMigrationTests { func roundTripSafeModeFull() throws { let id = UUID() let connection = DatabaseConnection( - id: id, name: "SafeModeFull Test", host: "127.0.0.1", port: 3306, + id: id, name: "SafeModeFull Test", host: "127.0.0.1", port: 3_306, database: "test", username: "root", type: .mysql, safeModeLevel: .safeModeFull ) @@ -111,7 +119,7 @@ struct SafeModeMigrationTests { func roundTripReadOnly() throws { let id = UUID() let connection = DatabaseConnection( - id: id, name: "ReadOnly Test", host: "127.0.0.1", port: 3306, + id: id, name: "ReadOnly Test", host: "127.0.0.1", port: 3_306, database: "test", username: "root", type: .mysql, safeModeLevel: .readOnly ) @@ -122,6 +130,174 @@ struct SafeModeMigrationTests { #expect(found?.safeModeLevel == .readOnly) } + @Test("setSafeModeLevel updates the active session and saved connection default") + func setSafeModeLevelPersistsUpdatedDefault() { + let id = UUID() + let connection = DatabaseConnection( + id: id, + name: "Persisted Safe Mode", + host: "127.0.0.1", + port: 3_306, + database: "test", + username: "root", + type: .mysql, + safeModeLevel: .silent + ) + + storage.addConnection(connection) + tracker.clearDirty(.connection, id: id.uuidString) + + let manager = DatabaseManager(connectionStorage: storage) + manager.injectSession(ConnectionSession(connection: connection), for: id) + defer { manager.removeSession(for: id) } + + manager.setSafeModeLevel(.readOnly, for: id) + + let session = manager.session(for: id) + let saved = storage.loadConnections().first { $0.id == id } + + #expect(session?.safeModeLevel == .readOnly) + #expect(session?.connection.safeModeLevel == .readOnly) + #expect(saved?.safeModeLevel == .readOnly) + #expect(tracker.dirtyRecords(for: .connection).contains(id.uuidString)) + } + + @Test("resolvedConnectionDefinition prefers the persisted safe mode over a stale caller copy") + func resolvedConnectionDefinitionUsesPersistedSafeMode() { + let id = UUID() + let staleConnection = DatabaseConnection( + id: id, + name: "Stale Safe Mode", + host: "127.0.0.1", + port: 3_306, + database: "test", + username: "root", + type: .mysql, + safeModeLevel: .silent + ) + + storage.addConnection(staleConnection) + + let manager = DatabaseManager(connectionStorage: storage) + manager.injectSession(ConnectionSession(connection: staleConnection), for: id) + manager.setSafeModeLevel(.alertFull, for: id) + manager.removeSession(for: id) + + let resolved = manager.resolvedConnectionDefinition(for: staleConnection) + + #expect(staleConnection.safeModeLevel == .silent) + #expect(resolved.safeModeLevel == .alertFull) + } + + @Test("resolvedConnectionDefinition keeps in-session connection edits and only refreshes safe mode") + func resolvedConnectionDefinitionPreservesInSessionEdits() { + let id = UUID() + let stored = DatabaseConnection( + id: id, + name: "Switched Database", + host: "127.0.0.1", + port: 5_432, + database: "original", + username: "postgres", + type: .postgresql, + safeModeLevel: .silent + ) + + storage.addConnection(stored) + + let manager = DatabaseManager(connectionStorage: storage) + manager.injectSession(ConnectionSession(connection: stored), for: id) + manager.setSafeModeLevel(.alertFull, for: id) + manager.removeSession(for: id) + + var inSession = stored + inSession.database = "switched" + + let resolved = manager.resolvedConnectionDefinition(for: inSession) + + #expect(resolved.database == "switched") + #expect(resolved.safeModeLevel == .alertFull) + } + + @Test("A fresh session seeds from the persisted safe mode after disconnect") + func freshSessionSeedsFromPersistedSafeMode() { + let id = UUID() + let connection = DatabaseConnection( + id: id, + name: "Reconnect Safe Mode", + host: "127.0.0.1", + port: 5_432, + database: "test", + username: "postgres", + type: .postgresql, + safeModeLevel: .silent + ) + + storage.addConnection(connection) + + let manager = DatabaseManager(connectionStorage: storage) + manager.injectSession(ConnectionSession(connection: connection), for: id) + manager.setSafeModeLevel(.alertFull, for: id) + manager.removeSession(for: id) + + let reloaded = storage.loadConnections().first { $0.id == id } + let reseededSession = reloaded.map { ConnectionSession(connection: $0) } + + #expect(reloaded?.safeModeLevel == .alertFull) + #expect(reseededSession?.safeModeLevel == .alertFull) + } + + @Test("updateSafeModeLevel preserves the saved password and marks sync dirty") + func updateSafeModeLevelPreservesPasswordAndMarksDirty() { + let id = UUID() + let connection = DatabaseConnection( + id: id, + name: "Password Preservation", + host: "127.0.0.1", + port: 3_306, + database: "test", + username: "root", + type: .mysql, + safeModeLevel: .silent + ) + + storage.addConnection(connection, password: "secret") + tracker.clearDirty(.connection, id: id.uuidString) + defer { storage.deletePassword(for: id) } + + let updated = storage.updateSafeModeLevel(.safeModeFull, for: id) + + #expect(updated) + #expect(storage.loadPassword(for: id) == "secret") + #expect(storage.loadConnection(id: id)?.safeModeLevel == .safeModeFull) + #expect(tracker.dirtyRecords(for: .connection).contains(id.uuidString)) + } + + @Test("updateSafeModeLevel skips sync dirtiness for local-only connections") + func updateSafeModeLevelSkipsSyncForLocalOnlyConnections() { + let id = UUID() + let connection = DatabaseConnection( + id: id, + name: "Local Safe Mode", + host: "127.0.0.1", + port: 3_306, + database: "test", + username: "root", + type: .mysql, + safeModeLevel: .silent, + localOnly: true + ) + + storage.addConnection(connection) + tracker.clearDirty(.connection, id: id.uuidString) + + let updated = storage.updateSafeModeLevel(.readOnly, for: id) + + #expect(updated) + #expect(storage.loadConnection(id: id)?.safeModeLevel == .readOnly) + #expect(!tracker.dirtyRecords(for: .connection).contains(id.uuidString)) + } + // MARK: - Default Level @Test("New connection defaults to silent safe mode level")