From fae307b1d1c4ff8bf4c18b9a644acfadb4d98b16 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 30 Apr 2026 21:47:42 +0700 Subject: [PATCH 01/29] fix(ssh): expand tilde in agent socket and identityAgent paths --- TablePro/Core/SSH/LibSSH2TunnelFactory.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/TablePro/Core/SSH/LibSSH2TunnelFactory.swift b/TablePro/Core/SSH/LibSSH2TunnelFactory.swift index 189ca5ce1..5d8144648 100644 --- a/TablePro/Core/SSH/LibSSH2TunnelFactory.swift +++ b/TablePro/Core/SSH/LibSSH2TunnelFactory.swift @@ -508,9 +508,9 @@ internal enum LibSSH2TunnelFactory { // Resolve agent socket: UI config > SSH config IdentityAgent > system default let socketPath: String? if !config.agentSocketPath.isEmpty { - socketPath = config.agentSocketPath + socketPath = SSHPathUtilities.expandTilde(config.agentSocketPath) } else if let agentPath = configEntry?.identityAgent, !agentPath.isEmpty { - socketPath = agentPath + socketPath = SSHPathUtilities.expandTilde(agentPath) } else { socketPath = nil } From 48b9e0e79100cabae5e9725449374e535f7b38d5 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 30 Apr 2026 21:48:03 +0700 Subject: [PATCH 02/29] fix(storage): persist group deletions before firing sync notification --- TablePro/Core/Storage/GroupStorage.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/TablePro/Core/Storage/GroupStorage.swift b/TablePro/Core/Storage/GroupStorage.swift index cbc7c4120..9ca99611f 100644 --- a/TablePro/Core/Storage/GroupStorage.swift +++ b/TablePro/Core/Storage/GroupStorage.swift @@ -85,13 +85,13 @@ final class GroupStorage { let descendantIds = collectAllDescendantGroupIds(groupId: group.id, groups: groups) let allIdsToDelete = descendantIds.union([group.id]) + groups.removeAll { allIdsToDelete.contains($0.id) } + saveGroups(groups) + for deletedId in allIdsToDelete { SyncChangeTracker.shared.markDeleted(.group, id: deletedId.uuidString) } - groups.removeAll { allIdsToDelete.contains($0.id) } - saveGroups(groups) - let storage = ConnectionStorage.shared var connections = storage.loadConnections() var changed = false From 3a096bc15cd5557139e062c68625c2b81bd4f83b Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 30 Apr 2026 22:05:14 +0700 Subject: [PATCH 03/29] fix(sql): throw when database dialect cannot be resolved --- .../ChangeTracking/DataChangeManager.swift | 2 +- .../SQLStatementGenerator.swift | 9 +- .../Infrastructure/SessionStateFactory.swift | 31 ++- .../Utilities/SQL/DialectQuoteHelper.swift | 33 ++- .../SQL/SQLRowToStatementConverter.swift | 20 +- TablePro/Models/Query/QueryTab.swift | 5 +- TablePro/Models/Query/QueryTabManager.swift | 12 +- .../MainContentCoordinator+FKNavigation.swift | 20 +- .../MainContentCoordinator+Navigation.swift | 128 ++++++----- .../Extensions/MainContentView+Setup.swift | 16 +- .../Views/Main/MainContentCoordinator.swift | 2 +- .../Results/DataGridView+RowActions.swift | 55 +++-- ...QLStatementGeneratorCompositePKTests.swift | 88 ++++---- .../SQLStatementGeneratorMSSQLTests.swift | 36 +-- .../SQLStatementGeneratorNoPKTests.swift | 64 +++--- ...LStatementGeneratorPKRegressionTests.swift | 32 +-- ...tatementGeneratorParameterStyleTests.swift | 44 ++-- .../SQLStatementGeneratorTests.swift | 208 +++++++++--------- .../SQLRowToStatementConverterTests.swift | 80 +++---- .../Models/EditorTabPayloadTests.swift | 4 +- TableProTests/Models/PreviewTabTests.swift | 16 +- .../Models/Query/QueryTabManagerTests.swift | 14 +- .../Query/TabStructureVersionTests.swift | 38 ++-- .../Main/CoordinatorEditorLoadTests.swift | 8 +- TableProTests/Views/Main/EvictionTests.swift | 2 +- .../Main/MultiConnectionNavigationTests.swift | 28 +-- .../Main/RowOperationsDispatchTests.swift | 2 +- .../Main/SortCacheInvalidationTests.swift | 2 +- .../Views/Main/TableRowsMutationTests.swift | 32 +-- .../Views/SidebarNavigationResultTests.swift | 12 +- TableProTests/Views/SwitchDatabaseTests.swift | 18 +- 31 files changed, 574 insertions(+), 487 deletions(-) diff --git a/TablePro/Core/ChangeTracking/DataChangeManager.swift b/TablePro/Core/ChangeTracking/DataChangeManager.swift index 47e392a7d..d25e50b65 100644 --- a/TablePro/Core/ChangeTracking/DataChangeManager.swift +++ b/TablePro/Core/ChangeTracking/DataChangeManager.swift @@ -421,7 +421,7 @@ final class DataChangeManager: ChangeManaging { ) } - let generator = SQLStatementGenerator( + let generator = try SQLStatementGenerator( tableName: tableName, columns: columns, primaryKeyColumns: primaryKeyColumns, diff --git a/TablePro/Core/ChangeTracking/SQLStatementGenerator.swift b/TablePro/Core/ChangeTracking/SQLStatementGenerator.swift index ebcb83508..76bc6ecd8 100644 --- a/TablePro/Core/ChangeTracking/SQLStatementGenerator.swift +++ b/TablePro/Core/ChangeTracking/SQLStatementGenerator.swift @@ -35,13 +35,18 @@ struct SQLStatementGenerator { parameterStyle: ParameterStyle? = nil, dialect: SQLDialectDescriptor? = nil, quoteIdentifier: ((String) -> String)? = nil - ) { + ) throws { self.tableName = tableName self.columns = columns self.primaryKeyColumns = primaryKeyColumns self.databaseType = databaseType self.parameterStyle = parameterStyle ?? Self.defaultParameterStyle(for: databaseType) - self.quoteIdentifierFn = quoteIdentifier ?? quoteIdentifierFromDialect(dialect) + if let quoteIdentifier { + self.quoteIdentifierFn = quoteIdentifier + } else { + let resolvedDialect = try resolveSQLDialect(for: databaseType, explicit: dialect) + self.quoteIdentifierFn = quoteIdentifierFromDialect(resolvedDialect) + } } private static func defaultParameterStyle(for databaseType: DatabaseType) -> ParameterStyle { diff --git a/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift b/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift index 4f2d27a7b..099912e81 100644 --- a/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift +++ b/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift @@ -7,6 +7,9 @@ // import Foundation +import os + +private let sessionStateLogger = Logger(subsystem: "com.TablePro", category: "SessionStateFactory") @MainActor enum SessionStateFactory { @@ -75,18 +78,22 @@ enum SessionStateFactory { case .table: toolbarSt.isTableTab = true if let tableName = payload.tableName { - if payload.isPreview { - tabMgr.addPreviewTableTab( - tableName: tableName, - databaseType: connection.type, - databaseName: payload.databaseName ?? connection.database - ) - } else { - tabMgr.addTableTab( - tableName: tableName, - databaseType: connection.type, - databaseName: payload.databaseName ?? connection.database - ) + do { + if payload.isPreview { + try tabMgr.addPreviewTableTab( + tableName: tableName, + databaseType: connection.type, + databaseName: payload.databaseName ?? connection.database + ) + } else { + try tabMgr.addTableTab( + tableName: tableName, + databaseType: connection.type, + databaseName: payload.databaseName ?? connection.database + ) + } + } catch { + sessionStateLogger.error("create tab for table failed: \(error.localizedDescription, privacy: .public)") } if let index = tabMgr.selectedTabIndex { tabMgr.tabs[index].tableContext.isView = payload.isView diff --git a/TablePro/Core/Utilities/SQL/DialectQuoteHelper.swift b/TablePro/Core/Utilities/SQL/DialectQuoteHelper.swift index 69eb3ccfb..1ae4282d9 100644 --- a/TablePro/Core/Utilities/SQL/DialectQuoteHelper.swift +++ b/TablePro/Core/Utilities/SQL/DialectQuoteHelper.swift @@ -2,16 +2,25 @@ // DialectQuoteHelper.swift // TablePro // -// Builds an identifier-quoting closure from a SQL dialect descriptor. -// import Foundation import TableProPluginKit -/// Build an identifier-quoting closure from a dialect descriptor. -/// NoSQL databases (nil dialect) use identity (return name as-is). -func quoteIdentifierFromDialect(_ dialect: SQLDialectDescriptor?) -> (String) -> String { - guard let dialect else { return { $0 } } +enum SQLDialectError: Error, LocalizedError { + case dialectUnavailable(typeId: String) + + var errorDescription: String? { + switch self { + case .dialectUnavailable(let typeId): + return String( + format: String(localized: "SQL dialect for %@ is not available. The plugin may not be installed or loaded."), + typeId + ) + } + } +} + +func quoteIdentifierFromDialect(_ dialect: SQLDialectDescriptor) -> (String) -> String { let q = dialect.identifierQuote if q == "[" { return { name in @@ -24,3 +33,15 @@ func quoteIdentifierFromDialect(_ dialect: SQLDialectDescriptor?) -> (String) -> return "\(q)\(escaped)\(q)" } } + +func resolveSQLDialect( + for databaseType: DatabaseType, + explicit: SQLDialectDescriptor? = nil +) throws -> SQLDialectDescriptor { + if let explicit { return explicit } + if let dialect = PluginMetadataRegistry.shared + .snapshot(forTypeId: databaseType.pluginTypeId)?.editor.sqlDialect { + return dialect + } + throw SQLDialectError.dialectUnavailable(typeId: databaseType.pluginTypeId) +} diff --git a/TablePro/Core/Utilities/SQL/SQLRowToStatementConverter.swift b/TablePro/Core/Utilities/SQL/SQLRowToStatementConverter.swift index 025b93473..bae8f2164 100644 --- a/TablePro/Core/Utilities/SQL/SQLRowToStatementConverter.swift +++ b/TablePro/Core/Utilities/SQL/SQLRowToStatementConverter.swift @@ -21,21 +21,27 @@ internal struct SQLRowToStatementConverter { dialect: SQLDialectDescriptor? = nil, quoteIdentifier: ((String) -> String)? = nil, escapeStringLiteral: ((String) -> String)? = nil - ) { + ) throws { self.tableName = tableName self.columns = columns self.primaryKeyColumn = primaryKeyColumn self.databaseType = databaseType - self.quoteIdentifierFn = quoteIdentifier ?? quoteIdentifierFromDialect(dialect) - self.escapeStringFn = escapeStringLiteral ?? Self.defaultEscapeFunction(dialect: dialect) + + if let quoteIdentifier, let escapeStringLiteral { + self.quoteIdentifierFn = quoteIdentifier + self.escapeStringFn = escapeStringLiteral + return + } + + let resolvedDialect = try resolveSQLDialect(for: databaseType, explicit: dialect) + self.quoteIdentifierFn = quoteIdentifier ?? quoteIdentifierFromDialect(resolvedDialect) + self.escapeStringFn = escapeStringLiteral ?? Self.defaultEscapeFunction(dialect: resolvedDialect) } private static let maxRows = 50_000 - /// Fallback escape function when no plugin driver is available. - /// Dialects with `requiresBackslashEscaping` get backslash escaping; others use ANSI SQL. - private static func defaultEscapeFunction(dialect: SQLDialectDescriptor?) -> (String) -> String { - if dialect?.requiresBackslashEscaping == true { + private static func defaultEscapeFunction(dialect: SQLDialectDescriptor) -> (String) -> String { + if dialect.requiresBackslashEscaping { return { value in var result = value result = result.replacingOccurrences(of: "\\", with: "\\\\") diff --git a/TablePro/Models/Query/QueryTab.swift b/TablePro/Models/Query/QueryTab.swift index 01e533363..aa57a3587 100644 --- a/TablePro/Models/Query/QueryTab.swift +++ b/TablePro/Models/Query/QueryTab.swift @@ -94,8 +94,7 @@ struct QueryTab: Identifiable, Equatable { databaseType: DatabaseType, schemaName: String? = nil, quoteIdentifier: ((String) -> String)? = nil - ) -> String { - let quote = quoteIdentifier ?? quoteIdentifierFromDialect(PluginManager.shared.sqlDialect(for: databaseType)) + ) throws -> String { let pageSize = AppSettingsManager.shared.dataGrid.defaultPageSize if let pluginDriver = PluginManager.shared.queryBuildingDriver(for: databaseType), @@ -112,6 +111,8 @@ struct QueryTab: Identifiable, Equatable { case .bash: return "SCAN 0 MATCH * COUNT \(pageSize)" default: + let dialect = try resolveSQLDialect(for: databaseType) + let quote = quoteIdentifier ?? quoteIdentifierFromDialect(dialect) let qualifiedName: String if let schema = schemaName, !schema.isEmpty { qualifiedName = "\(quote(schema)).\(quote(tableName))" diff --git a/TablePro/Models/Query/QueryTabManager.swift b/TablePro/Models/Query/QueryTabManager.swift index 2276f5eef..98c1be0f2 100644 --- a/TablePro/Models/Query/QueryTabManager.swift +++ b/TablePro/Models/Query/QueryTabManager.swift @@ -103,7 +103,7 @@ final class QueryTabManager { databaseType: DatabaseType = .mysql, databaseName: String = "", quoteIdentifier: ((String) -> String)? = nil - ) { + ) throws { if let existingTab = tabs.first(where: { $0.tabType == .table && $0.tableContext.tableName == tableName && $0.tableContext.databaseName == databaseName }) { @@ -112,7 +112,7 @@ final class QueryTabManager { } let pageSize = AppSettingsManager.shared.dataGrid.defaultPageSize - let query = QueryTab.buildBaseTableQuery( + let query = try QueryTab.buildBaseTableQuery( tableName: tableName, databaseType: databaseType, quoteIdentifier: quoteIdentifier ) var newTab = QueryTab( @@ -180,9 +180,9 @@ final class QueryTabManager { databaseType: DatabaseType = .mysql, databaseName: String = "", quoteIdentifier: ((String) -> String)? = nil - ) { + ) throws { let pageSize = AppSettingsManager.shared.dataGrid.defaultPageSize - let query = QueryTab.buildBaseTableQuery( + let query = try QueryTab.buildBaseTableQuery( tableName: tableName, databaseType: databaseType, quoteIdentifier: quoteIdentifier ) var newTab = QueryTab( @@ -207,14 +207,14 @@ final class QueryTabManager { isView: Bool = false, databaseName: String = "", schemaName: String? = nil, isPreview: Bool = false, quoteIdentifier: ((String) -> String)? = nil - ) -> Bool { + ) throws -> Bool { guard let selectedId = selectedTabId, let selectedIndex = tabs.firstIndex(where: { $0.id == selectedId }) else { return false } - let query = QueryTab.buildBaseTableQuery( + let query = try QueryTab.buildBaseTableQuery( tableName: tableName, databaseType: databaseType, schemaName: schemaName, diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+FKNavigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+FKNavigation.swift index 40b8a82fb..41fdc3b26 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+FKNavigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+FKNavigation.swift @@ -73,13 +73,19 @@ extension MainContentCoordinator { return } - let needsQuery = tabManager.replaceTabContent( - tableName: referencedTable, - databaseType: connection.type, - isView: false, - databaseName: currentDatabase, - schemaName: targetSchema - ) + let needsQuery: Bool + do { + needsQuery = try tabManager.replaceTabContent( + tableName: referencedTable, + databaseType: connection.type, + isView: false, + databaseName: currentDatabase, + schemaName: targetSchema + ) + } catch { + fkNavigationLogger.error("navigateToFKReference replaceTabContent failed: \(error.localizedDescription, privacy: .public)") + return + } if needsQuery, let (tab, tabIndex) = tabManager.selectedTabAndIndex { setActiveTableRows(TableRows(), for: tab.id) diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift index 3972cbb19..97aa94f0e 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift @@ -51,11 +51,15 @@ extension MainContentCoordinator { // opening a new native window tab. if sidebarLoadingState == .loading { if tabManager.tabs.isEmpty { - tabManager.addTableTab( - tableName: tableName, - databaseType: connection.type, - databaseName: currentDatabase - ) + do { + try tabManager.addTableTab( + tableName: tableName, + databaseType: connection.type, + databaseName: currentDatabase + ) + } catch { + navigationLogger.error("openTableTab addTableTab failed: \(error.localizedDescription, privacy: .public)") + } } return } @@ -74,22 +78,27 @@ extension MainContentCoordinator { // If no tabs exist (empty state), add a table tab directly. // In preview mode, mark it as preview so subsequent clicks replace it. if tabManager.tabs.isEmpty { - if AppSettingsManager.shared.tabs.enablePreviewTabs { - tabManager.addPreviewTableTab( - tableName: tableName, - databaseType: connection.type, - databaseName: currentDatabase - ) - if let wid = windowId { - WindowLifecycleMonitor.shared.setPreview(true, for: wid) - WindowLifecycleMonitor.shared.window(for: wid)?.subtitle = "\(connection.name) — Preview" + do { + if AppSettingsManager.shared.tabs.enablePreviewTabs { + try tabManager.addPreviewTableTab( + tableName: tableName, + databaseType: connection.type, + databaseName: currentDatabase + ) + if let wid = windowId { + WindowLifecycleMonitor.shared.setPreview(true, for: wid) + WindowLifecycleMonitor.shared.window(for: wid)?.subtitle = "\(connection.name) — Preview" + } + } else { + try tabManager.addTableTab( + tableName: tableName, + databaseType: connection.type, + databaseName: currentDatabase + ) } - } else { - tabManager.addTableTab( - tableName: tableName, - databaseType: connection.type, - databaseName: currentDatabase - ) + } catch { + navigationLogger.error("openTableTab tab creation failed: \(error.localizedDescription, privacy: .public)") + return } if let (_, tabIndex) = tabManager.selectedTabAndIndex { tabManager.tabs[tabIndex].tableContext.isView = isView @@ -116,23 +125,28 @@ extension MainContentCoordinator { if let oldTab = tabManager.selectedTab, let oldTableName = oldTab.tableContext.tableName { filterStateManager.saveLastFilters(for: oldTableName) } - if tabManager.replaceTabContent( - tableName: tableName, - databaseType: connection.type, - databaseName: currentDatabase, - schemaName: currentSchema - ) { - filterStateManager.clearAll() - if let (tab, tabIndex) = tabManager.selectedTabAndIndex { - setActiveTableRows(TableRows(), for: tab.id) - tabManager.tabs[tabIndex].pagination.reset() - toolbarState.isTableTab = true - } - restoreLastHiddenColumnsForTable(tableName) - restoreFiltersForTable(tableName) - if let dbIndex = Int(currentDatabase) { - selectRedisDatabaseAndQuery(dbIndex) + do { + let replaced = try tabManager.replaceTabContent( + tableName: tableName, + databaseType: connection.type, + databaseName: currentDatabase, + schemaName: currentSchema + ) + if replaced { + filterStateManager.clearAll() + if let (tab, tabIndex) = tabManager.selectedTabAndIndex { + setActiveTableRows(TableRows(), for: tab.id) + tabManager.tabs[tabIndex].pagination.reset() + toolbarState.isTableTab = true + } + restoreLastHiddenColumnsForTable(tableName) + restoreFiltersForTable(tableName) + if let dbIndex = Int(currentDatabase) { + selectRedisDatabaseAndQuery(dbIndex) + } } + } catch { + navigationLogger.error("openTableTab replaceTabContent failed: \(error.localizedDescription, privacy: .public)") } return } @@ -195,14 +209,19 @@ extension MainContentCoordinator { let oldTableName = oldTab.tableContext.tableName { previewCoordinator.filterStateManager.saveLastFilters(for: oldTableName) } - previewCoordinator.tabManager.replaceTabContent( - tableName: tableName, - databaseType: connection.type, - isView: isView, - databaseName: databaseName, - schemaName: schemaName, - isPreview: true - ) + do { + try previewCoordinator.tabManager.replaceTabContent( + tableName: tableName, + databaseType: connection.type, + isView: isView, + databaseName: databaseName, + schemaName: schemaName, + isPreview: true + ) + } catch { + navigationLogger.error("openPreviewTab replaceTabContent failed: \(error.localizedDescription, privacy: .public)") + return + } previewCoordinator.filterStateManager.clearAll() if let tabIndex = previewCoordinator.tabManager.selectedTabIndex { let tabId = previewCoordinator.tabManager.tabs[tabIndex].id @@ -267,14 +286,19 @@ extension MainContentCoordinator { if let oldTableName = selectedTab.tableContext.tableName { filterStateManager.saveLastFilters(for: oldTableName) } - tabManager.replaceTabContent( - tableName: tableName, - databaseType: connection.type, - isView: isView, - databaseName: databaseName, - schemaName: schemaName, - isPreview: true - ) + do { + try tabManager.replaceTabContent( + tableName: tableName, + databaseType: connection.type, + isView: isView, + databaseName: databaseName, + schemaName: schemaName, + isPreview: true + ) + } catch { + navigationLogger.error("openPreviewTab replaceTabContent failed: \(error.localizedDescription, privacy: .public)") + return + } filterStateManager.clearAll() if let (tab, tabIndex) = tabManager.selectedTabAndIndex { setActiveTableRows(TableRows(), for: tab.id) diff --git a/TablePro/Views/Main/Extensions/MainContentView+Setup.swift b/TablePro/Views/Main/Extensions/MainContentView+Setup.swift index 82f540198..a884c2c02 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+Setup.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+Setup.swift @@ -115,11 +115,17 @@ extension MainContentView { var restoredTabs = result.tabs for i in restoredTabs.indices where restoredTabs[i].tabType == .table { if let tableName = restoredTabs[i].tableContext.tableName { - restoredTabs[i].content.query = QueryTab.buildBaseTableQuery( - tableName: tableName, - databaseType: connection.type, - schemaName: restoredTabs[i].tableContext.schemaName - ) + do { + restoredTabs[i].content.query = try QueryTab.buildBaseTableQuery( + tableName: tableName, + databaseType: connection.type, + schemaName: restoredTabs[i].tableContext.schemaName + ) + } catch { + MainContentView.lifecycleLogger.error( + "[open] buildBaseTableQuery failed for restored tab table=\(tableName, privacy: .public): \(error.localizedDescription, privacy: .public)" + ) + } } } diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 49cc252c5..8590d2e5d 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -390,7 +390,7 @@ final class MainContentCoordinator { self.queryBuilder = TableQueryBuilder( databaseType: connection.type, dialect: dialect, - dialectQuote: quoteIdentifierFromDialect(dialect) + dialectQuote: dialect.map { quoteIdentifierFromDialect($0) } ) self.persistence = TabPersistenceCoordinator(connectionId: connection.id) diff --git a/TablePro/Views/Results/DataGridView+RowActions.swift b/TablePro/Views/Results/DataGridView+RowActions.swift index 3f073dba8..36df9f297 100644 --- a/TablePro/Views/Results/DataGridView+RowActions.swift +++ b/TablePro/Views/Results/DataGridView+RowActions.swift @@ -6,6 +6,9 @@ // import AppKit +import os + +private let rowActionsLogger = Logger(subsystem: "com.TablePro", category: "DataGridView+RowActions") // MARK: - Row Actions @@ -108,34 +111,42 @@ extension TableViewCoordinator { guard let tableName, let databaseType else { return } let tableRows = tableRowsProvider() let driver = resolveDriver() - let converter = SQLRowToStatementConverter( - tableName: tableName, - columns: tableRows.columns, - primaryKeyColumn: primaryKeyColumn, - databaseType: databaseType, - quoteIdentifier: driver?.quoteIdentifier, - escapeStringLiteral: driver?.escapeStringLiteral - ) - let rows = indices.sorted().compactMap { displayRow(at: $0)?.values } - guard !rows.isEmpty else { return } - ClipboardService.shared.writeText(converter.generateInserts(rows: rows)) + do { + let converter = try SQLRowToStatementConverter( + tableName: tableName, + columns: tableRows.columns, + primaryKeyColumn: primaryKeyColumn, + databaseType: databaseType, + quoteIdentifier: driver?.quoteIdentifier, + escapeStringLiteral: driver?.escapeStringLiteral + ) + let rows = indices.sorted().compactMap { displayRow(at: $0)?.values } + guard !rows.isEmpty else { return } + ClipboardService.shared.writeText(converter.generateInserts(rows: rows)) + } catch { + rowActionsLogger.error("copyRowsAsInsert failed: \(error.localizedDescription, privacy: .public)") + } } func copyRowsAsUpdate(at indices: Set) { guard let tableName, let databaseType else { return } let tableRows = tableRowsProvider() let driver = resolveDriver() - let converter = SQLRowToStatementConverter( - tableName: tableName, - columns: tableRows.columns, - primaryKeyColumn: primaryKeyColumn, - databaseType: databaseType, - quoteIdentifier: driver?.quoteIdentifier, - escapeStringLiteral: driver?.escapeStringLiteral - ) - let rows = indices.sorted().compactMap { displayRow(at: $0)?.values } - guard !rows.isEmpty else { return } - ClipboardService.shared.writeText(converter.generateUpdates(rows: rows)) + do { + let converter = try SQLRowToStatementConverter( + tableName: tableName, + columns: tableRows.columns, + primaryKeyColumn: primaryKeyColumn, + databaseType: databaseType, + quoteIdentifier: driver?.quoteIdentifier, + escapeStringLiteral: driver?.escapeStringLiteral + ) + let rows = indices.sorted().compactMap { displayRow(at: $0)?.values } + guard !rows.isEmpty else { return } + ClipboardService.shared.writeText(converter.generateUpdates(rows: rows)) + } catch { + rowActionsLogger.error("copyRowsAsUpdate failed: \(error.localizedDescription, privacy: .public)") + } } func copyRowsAsJson(at indices: Set) { diff --git a/TableProTests/Core/ChangeTracking/SQLStatementGeneratorCompositePKTests.swift b/TableProTests/Core/ChangeTracking/SQLStatementGeneratorCompositePKTests.swift index 82dc730af..aac039136 100644 --- a/TableProTests/Core/ChangeTracking/SQLStatementGeneratorCompositePKTests.swift +++ b/TableProTests/Core/ChangeTracking/SQLStatementGeneratorCompositePKTests.swift @@ -17,8 +17,8 @@ struct SQLStatementGeneratorCompositePKTests { columns: [String] = ["order_id", "product_id", "quantity", "price"], primaryKeyColumns: [String] = ["order_id", "product_id"], databaseType: DatabaseType = .mysql - ) -> SQLStatementGenerator { - SQLStatementGenerator( + ) throws -> SQLStatementGenerator { + try SQLStatementGenerator( tableName: tableName, columns: columns, primaryKeyColumns: primaryKeyColumns, @@ -80,8 +80,8 @@ struct SQLStatementGeneratorCompositePKTests { // MARK: - UPDATE: Composite PK WHERE Clause @Test("UPDATE with 2-column composite PK produces AND in WHERE") - func updateCompositePKBasic() { - let gen = makeGenerator() + func updateCompositePKBasic() throws { + let gen = try makeGenerator() let stmts = generate([ makeUpdateChange( columnIndex: 2, columnName: "quantity", @@ -98,8 +98,8 @@ struct SQLStatementGeneratorCompositePKTests { } @Test("UPDATE with 3-column composite PK produces multiple ANDs") - func updateThreeColumnCompositePK() { - let gen = makeGenerator( + func updateThreeColumnCompositePK() throws { + let gen = try makeGenerator( columns: ["tenant_id", "user_id", "role_id", "active"], primaryKeyColumns: ["tenant_id", "user_id", "role_id"] ) @@ -120,8 +120,8 @@ struct SQLStatementGeneratorCompositePKTests { } @Test("UPDATE preserves correct parameter order: SET values before WHERE values") - func updateParameterOrder() { - let gen = makeGenerator() + func updateParameterOrder() throws { + let gen = try makeGenerator() let stmts = generate([ makeUpdateChange( columnIndex: 2, columnName: "quantity", @@ -138,8 +138,8 @@ struct SQLStatementGeneratorCompositePKTests { } @Test("UPDATE multiple columns on same row with composite PK") - func updateMultipleColumnsCompositePK() { - let gen = makeGenerator() + func updateMultipleColumnsCompositePK() throws { + let gen = try makeGenerator() let stmts = generate([ makeMultiCellUpdateChange( cellChanges: [ @@ -160,8 +160,8 @@ struct SQLStatementGeneratorCompositePKTests { } @Test("UPDATE where user edits a PK column uses original value in WHERE") - func updateEditsPKColumn() { - let gen = makeGenerator() + func updateEditsPKColumn() throws { + let gen = try makeGenerator() let stmts = generate([ makeUpdateChange( columnIndex: 1, columnName: "product_id", @@ -179,8 +179,8 @@ struct SQLStatementGeneratorCompositePKTests { } @Test("Multiple UPDATE changes generate separate statements") - func multipleUpdatesCompositePK() { - let gen = makeGenerator() + func multipleUpdatesCompositePK() throws { + let gen = try makeGenerator() let stmts = generate([ makeUpdateChange( rowIndex: 0, columnIndex: 2, columnName: "quantity", @@ -206,8 +206,8 @@ struct SQLStatementGeneratorCompositePKTests { // MARK: - UPDATE: Database Dialects @Test("PostgreSQL UPDATE with composite PK uses $N placeholders") - func updateCompositePKPostgreSQL() { - let gen = makeGenerator(databaseType: .postgresql) + func updateCompositePKPostgreSQL() throws { + let gen = try makeGenerator(databaseType: .postgresql) let stmts = generate([ makeUpdateChange( columnIndex: 2, columnName: "quantity", @@ -224,8 +224,8 @@ struct SQLStatementGeneratorCompositePKTests { } @Test("MSSQL UPDATE with composite PK uses bracket quoting") - func updateCompositePKMSSQL() { - let gen = makeGenerator(databaseType: .mssql) + func updateCompositePKMSSQL() throws { + let gen = try makeGenerator(databaseType: .mssql) let stmts = generate([ makeUpdateChange( columnIndex: 2, columnName: "quantity", @@ -243,8 +243,8 @@ struct SQLStatementGeneratorCompositePKTests { // MARK: - DELETE: Composite PK @Test("Single row DELETE with composite PK uses AND") - func deleteSingleRowCompositePK() { - let gen = makeGenerator() + func deleteSingleRowCompositePK() throws { + let gen = try makeGenerator() let stmts = generate( [makeDeleteChange(rowIndex: 0, originalRow: ["1", "42", "5", "9.99"])], generator: gen, @@ -263,8 +263,8 @@ struct SQLStatementGeneratorCompositePKTests { } @Test("Batch DELETE with composite PK: (AND) per row, OR between rows") - func batchDeleteCompositePK() { - let gen = makeGenerator() + func batchDeleteCompositePK() throws { + let gen = try makeGenerator() let stmts = generate( [ makeDeleteChange(rowIndex: 0, originalRow: ["1", "42", "5", "9.99"]), @@ -284,8 +284,8 @@ struct SQLStatementGeneratorCompositePKTests { } @Test("Batch DELETE with composite PK on PostgreSQL uses $N") - func batchDeleteCompositePKPostgreSQL() { - let gen = makeGenerator(databaseType: .postgresql) + func batchDeleteCompositePKPostgreSQL() throws { + let gen = try makeGenerator(databaseType: .postgresql) let stmts = generate( [ makeDeleteChange(rowIndex: 0, originalRow: ["1", "42", "5", "9.99"]), @@ -306,8 +306,8 @@ struct SQLStatementGeneratorCompositePKTests { // MARK: - Single PK Regression @Test("Single PK UPDATE still works (regression)") - func singlePKUpdateRegression() { - let gen = makeGenerator( + func singlePKUpdateRegression() throws { + let gen = try makeGenerator( tableName: "users", columns: ["id", "name", "email"], primaryKeyColumns: ["id"] @@ -327,8 +327,8 @@ struct SQLStatementGeneratorCompositePKTests { } @Test("Single PK batch DELETE no parentheses (regression)") - func singlePKBatchDeleteRegression() { - let gen = makeGenerator( + func singlePKBatchDeleteRegression() throws { + let gen = try makeGenerator( tableName: "users", columns: ["id", "name", "email"], primaryKeyColumns: ["id"] @@ -353,8 +353,8 @@ struct SQLStatementGeneratorCompositePKTests { // MARK: - No PK Fallback @Test("No PK UPDATE falls back to all-column WHERE") - func noPKUpdateFallback() { - let gen = makeGenerator( + func noPKUpdateFallback() throws { + let gen = try makeGenerator( tableName: "logs", columns: ["ts", "message", "level"], primaryKeyColumns: [] @@ -375,8 +375,8 @@ struct SQLStatementGeneratorCompositePKTests { } @Test("No PK DELETE uses individual per-row statements with all columns") - func noPKDeleteFallback() { - let gen = makeGenerator( + func noPKDeleteFallback() throws { + let gen = try makeGenerator( tableName: "logs", columns: ["ts", "message", "level"], primaryKeyColumns: [] @@ -398,8 +398,8 @@ struct SQLStatementGeneratorCompositePKTests { } @Test("No PK fallback handles NULL values with IS NULL") - func noPKFallbackNullHandling() { - let gen = makeGenerator( + func noPKFallbackNullHandling() throws { + let gen = try makeGenerator( tableName: "logs", columns: ["ts", "message", "level"], primaryKeyColumns: [] @@ -422,8 +422,8 @@ struct SQLStatementGeneratorCompositePKTests { // MARK: - Edge Cases @Test("Composite PK with NULL value in one PK column skips UPDATE") - func compositePKNullValueSkipsUpdate() { - let gen = makeGenerator() + func compositePKNullValueSkipsUpdate() throws { + let gen = try makeGenerator() let stmts = generate([ makeUpdateChange( columnIndex: 2, columnName: "quantity", @@ -436,8 +436,8 @@ struct SQLStatementGeneratorCompositePKTests { } @Test("Composite PK with NULL in one PK column skips batch DELETE for that row") - func compositePKNullValueInBatchDelete() { - let gen = makeGenerator() + func compositePKNullValueInBatchDelete() throws { + let gen = try makeGenerator() let stmts = generate( [ makeDeleteChange(rowIndex: 0, originalRow: ["1", nil, "5", "9.99"]), @@ -453,8 +453,8 @@ struct SQLStatementGeneratorCompositePKTests { } @Test("UPDATE without originalRow falls back to cellChanges for PK value") - func updateWithoutOriginalRowUsesCellChanges() { - let gen = makeGenerator() + func updateWithoutOriginalRowUsesCellChanges() throws { + let gen = try makeGenerator() let change = RowChange( rowIndex: 0, type: .update, @@ -474,8 +474,8 @@ struct SQLStatementGeneratorCompositePKTests { } @Test("UPDATE without originalRow and missing PK in cellChanges is skipped") - func updateWithoutOriginalRowMissingPKSkipped() { - let gen = makeGenerator() + func updateWithoutOriginalRowMissingPKSkipped() throws { + let gen = try makeGenerator() let change = RowChange( rowIndex: 0, type: .update, @@ -491,8 +491,8 @@ struct SQLStatementGeneratorCompositePKTests { } @Test("Mixed INSERT + UPDATE + DELETE with composite PK generates correct statements") - func mixedOperationsCompositePK() { - let gen = makeGenerator() + func mixedOperationsCompositePK() throws { + let gen = try makeGenerator() let insertChange = RowChange(rowIndex: 3, type: .insert, cellChanges: []) let updateChange = makeUpdateChange( diff --git a/TableProTests/Core/ChangeTracking/SQLStatementGeneratorMSSQLTests.swift b/TableProTests/Core/ChangeTracking/SQLStatementGeneratorMSSQLTests.swift index f0c05340f..3506dafe8 100644 --- a/TableProTests/Core/ChangeTracking/SQLStatementGeneratorMSSQLTests.swift +++ b/TableProTests/Core/ChangeTracking/SQLStatementGeneratorMSSQLTests.swift @@ -17,8 +17,8 @@ struct SQLStatementGeneratorMSSQLTests { tableName: String = "users", columns: [String] = ["id", "name", "email"], primaryKeyColumns: [String] = ["id"] - ) -> SQLStatementGenerator { - SQLStatementGenerator( + ) throws -> SQLStatementGenerator { + try SQLStatementGenerator( tableName: tableName, columns: columns, primaryKeyColumns: primaryKeyColumns, @@ -64,8 +64,8 @@ struct SQLStatementGeneratorMSSQLTests { // MARK: - Placeholder Tests @Test("INSERT statement uses question mark placeholders") - func insertUsesQuestionMarkPlaceholders() { - let generator = makeGenerator() + func insertUsesQuestionMarkPlaceholders() throws { + let generator = try makeGenerator() let insertedRowData: [Int: [String?]] = [0: ["1", "John", "john@example.com"]] let statements = generator.generateStatements( from: [makeInsertChange()], @@ -80,8 +80,8 @@ struct SQLStatementGeneratorMSSQLTests { } @Test("UPDATE statement uses question mark placeholders") - func updateUsesQuestionMarkPlaceholders() { - let generator = makeGenerator() + func updateUsesQuestionMarkPlaceholders() throws { + let generator = try makeGenerator() let statements = generator.generateStatements( from: [makeUpdateChange()], insertedRowData: [:], @@ -97,8 +97,8 @@ struct SQLStatementGeneratorMSSQLTests { // MARK: - INSERT Tests @Test("INSERT uses bracket-quoted table and column names") - func insertBracketQuoting() { - let generator = makeGenerator() + func insertBracketQuoting() throws { + let generator = try makeGenerator() let insertedRowData: [Int: [String?]] = [0: ["1", "John", "john@example.com"]] let statements = generator.generateStatements( from: [makeInsertChange()], @@ -116,8 +116,8 @@ struct SQLStatementGeneratorMSSQLTests { } @Test("INSERT with multiple columns produces correct number of placeholders") - func insertMultipleColumnsPlaceholders() { - let generator = makeGenerator(columns: ["id", "name", "email"]) + func insertMultipleColumnsPlaceholders() throws { + let generator = try makeGenerator(columns: ["id", "name", "email"]) let insertedRowData: [Int: [String?]] = [0: ["1", "John", "john@example.com"]] let statements = generator.generateStatements( from: [makeInsertChange()], @@ -136,8 +136,8 @@ struct SQLStatementGeneratorMSSQLTests { // MARK: - UPDATE Tests @Test("UPDATE uses bracket-quoted table and column names") - func updateBracketQuoting() { - let generator = makeGenerator() + func updateBracketQuoting() throws { + let generator = try makeGenerator() let statements = generator.generateStatements( from: [makeUpdateChange()], insertedRowData: [:], @@ -151,8 +151,8 @@ struct SQLStatementGeneratorMSSQLTests { } @Test("UPDATE WHERE clause uses primary key") - func updateWhereClauseUsesPrimaryKey() { - let generator = makeGenerator() + func updateWhereClauseUsesPrimaryKey() throws { + let generator = try makeGenerator() let statements = generator.generateStatements( from: [makeUpdateChange()], insertedRowData: [:], @@ -168,8 +168,8 @@ struct SQLStatementGeneratorMSSQLTests { // MARK: - DELETE Tests @Test("DELETE uses bracket-quoted table name") - func deleteBracketQuoting() { - let generator = makeGenerator() + func deleteBracketQuoting() throws { + let generator = try makeGenerator() let statements = generator.generateStatements( from: [makeDeleteChange()], insertedRowData: [:], @@ -184,8 +184,8 @@ struct SQLStatementGeneratorMSSQLTests { } @Test("DELETE does not add LIMIT clause for MSSQL") - func deleteNoLimitClause() { - let generator = makeGenerator() + func deleteNoLimitClause() throws { + let generator = try makeGenerator() let statements = generator.generateStatements( from: [makeDeleteChange()], insertedRowData: [:], diff --git a/TableProTests/Core/ChangeTracking/SQLStatementGeneratorNoPKTests.swift b/TableProTests/Core/ChangeTracking/SQLStatementGeneratorNoPKTests.swift index ef1a43160..93ac4a824 100644 --- a/TableProTests/Core/ChangeTracking/SQLStatementGeneratorNoPKTests.swift +++ b/TableProTests/Core/ChangeTracking/SQLStatementGeneratorNoPKTests.swift @@ -18,8 +18,8 @@ struct SQLStatementGeneratorNoPKTests { columns: [String] = ["id", "name", "email"], primaryKeyColumns: [String] = [], databaseType: DatabaseType = .mysql - ) -> SQLStatementGenerator { - SQLStatementGenerator( + ) throws -> SQLStatementGenerator { + try SQLStatementGenerator( tableName: tableName, columns: columns, primaryKeyColumns: primaryKeyColumns, @@ -31,8 +31,8 @@ struct SQLStatementGeneratorNoPKTests { // MARK: - UPDATE without PK @Test("Update without primary key uses all columns in WHERE") - func testUpdateNoPrimaryKey() { - let generator = makeGenerator() + func testUpdateNoPrimaryKey() throws { + let generator = try makeGenerator() let changes: [RowChange] = [ RowChange( rowIndex: 0, @@ -64,8 +64,8 @@ struct SQLStatementGeneratorNoPKTests { } @Test("Update without PK — MySQL uses LIMIT 1") - func testUpdateNoPKMySQLLimit() { - let generator = makeGenerator(databaseType: .mysql) + func testUpdateNoPKMySQLLimit() throws { + let generator = try makeGenerator(databaseType: .mysql) let changes: [RowChange] = [ RowChange( rowIndex: 0, @@ -97,8 +97,8 @@ struct SQLStatementGeneratorNoPKTests { } @Test("Update without PK — PostgreSQL uses $N placeholders, no LIMIT") - func testUpdateNoPKPostgreSQLNoLimit() { - let generator = makeGenerator(databaseType: .postgresql) + func testUpdateNoPKPostgreSQLNoLimit() throws { + let generator = try makeGenerator(databaseType: .postgresql) let changes: [RowChange] = [ RowChange( rowIndex: 0, @@ -129,8 +129,8 @@ struct SQLStatementGeneratorNoPKTests { } @Test("Update without PK — SQLite uses LIMIT 1") - func testUpdateNoPKSQLiteLimit() { - let generator = makeGenerator(databaseType: .sqlite) + func testUpdateNoPKSQLiteLimit() throws { + let generator = try makeGenerator(databaseType: .sqlite) let changes: [RowChange] = [ RowChange( rowIndex: 0, @@ -156,8 +156,8 @@ struct SQLStatementGeneratorNoPKTests { } @Test("Update without PK — MSSQL uses UPDATE TOP (1)") - func testUpdateNoPKMSSQLTop() { - let generator = makeGenerator(databaseType: .mssql) + func testUpdateNoPKMSSQLTop() throws { + let generator = try makeGenerator(databaseType: .mssql) let changes: [RowChange] = [ RowChange( rowIndex: 0, @@ -184,8 +184,8 @@ struct SQLStatementGeneratorNoPKTests { } @Test("Update without PK — NULL in originalRow uses IS NULL") - func testUpdateNoPKWithNull() { - let generator = makeGenerator() + func testUpdateNoPKWithNull() throws { + let generator = try makeGenerator() let changes: [RowChange] = [ RowChange( rowIndex: 0, @@ -211,8 +211,8 @@ struct SQLStatementGeneratorNoPKTests { } @Test("Update without PK — missing originalRow returns empty") - func testUpdateNoPKMissingOriginalRow() { - let generator = makeGenerator() + func testUpdateNoPKMissingOriginalRow() throws { + let generator = try makeGenerator() let changes: [RowChange] = [ RowChange( rowIndex: 0, @@ -235,8 +235,8 @@ struct SQLStatementGeneratorNoPKTests { } @Test("Update without PK — multiple columns changed") - func testUpdateNoPKMultipleColumnsChanged() { - let generator = makeGenerator() + func testUpdateNoPKMultipleColumnsChanged() throws { + let generator = try makeGenerator() let changes: [RowChange] = [ RowChange( rowIndex: 0, @@ -266,8 +266,8 @@ struct SQLStatementGeneratorNoPKTests { // MARK: - DELETE without PK @Test("Delete without PK — MSSQL uses DELETE TOP (1)") - func testDeleteNoPKMSSQLTop() { - let generator = makeGenerator(databaseType: .mssql) + func testDeleteNoPKMSSQLTop() throws { + let generator = try makeGenerator(databaseType: .mssql) let changes: [RowChange] = [ RowChange( rowIndex: 0, @@ -291,8 +291,8 @@ struct SQLStatementGeneratorNoPKTests { } @Test("Delete without PK — SQLite uses LIMIT 1") - func testDeleteNoPKSQLiteLimit() { - let generator = makeGenerator(databaseType: .sqlite) + func testDeleteNoPKSQLiteLimit() throws { + let generator = try makeGenerator(databaseType: .sqlite) let changes: [RowChange] = [ RowChange( rowIndex: 0, @@ -316,8 +316,8 @@ struct SQLStatementGeneratorNoPKTests { } @Test("Delete without PK — multiple rows generate individual DELETEs") - func testDeleteNoPKMultipleRows() { - let generator = makeGenerator() + func testDeleteNoPKMultipleRows() throws { + let generator = try makeGenerator() let changes: [RowChange] = [ RowChange(rowIndex: 0, type: .delete, cellChanges: [], originalRow: ["1", "John", "john@example.com"]), RowChange(rowIndex: 1, type: .delete, cellChanges: [], originalRow: ["2", "Jane", "jane@example.com"]) @@ -338,8 +338,8 @@ struct SQLStatementGeneratorNoPKTests { } @Test("Delete without PK — all NULL originalRow uses IS NULL") - func testDeleteNoPKAllNull() { - let generator = makeGenerator() + func testDeleteNoPKAllNull() throws { + let generator = try makeGenerator() let changes: [RowChange] = [ RowChange( rowIndex: 0, @@ -365,8 +365,8 @@ struct SQLStatementGeneratorNoPKTests { } @Test("Delete without PK — missing originalRow returns empty") - func testDeleteNoPKMissingOriginalRow() { - let generator = makeGenerator() + func testDeleteNoPKMissingOriginalRow() throws { + let generator = try makeGenerator() let changes: [RowChange] = [ RowChange( rowIndex: 0, @@ -389,8 +389,8 @@ struct SQLStatementGeneratorNoPKTests { // MARK: - Mixed Operations without PK @Test("Mixed UPDATE + DELETE without PK generates both") - func testMixedUpdateDeleteNoPK() { - let generator = makeGenerator() + func testMixedUpdateDeleteNoPK() throws { + let generator = try makeGenerator() let changes: [RowChange] = [ RowChange( rowIndex: 0, @@ -421,8 +421,8 @@ struct SQLStatementGeneratorNoPKTests { } @Test("INSERT + DELETE without PK — INSERT unaffected") - func testInsertDeleteNoPK() { - let generator = makeGenerator() + func testInsertDeleteNoPK() throws { + let generator = try makeGenerator() let insertedRowData: [Int: [String?]] = [ 0: ["3", "Bob", "bob@example.com"] ] diff --git a/TableProTests/Core/ChangeTracking/SQLStatementGeneratorPKRegressionTests.swift b/TableProTests/Core/ChangeTracking/SQLStatementGeneratorPKRegressionTests.swift index fb5195ab8..37ed5edf6 100644 --- a/TableProTests/Core/ChangeTracking/SQLStatementGeneratorPKRegressionTests.swift +++ b/TableProTests/Core/ChangeTracking/SQLStatementGeneratorPKRegressionTests.swift @@ -16,8 +16,8 @@ struct SQLStatementGeneratorPKRegressionTests { columns: [String] = ["id", "name", "email"], primaryKeyColumns: [String] = ["id"], databaseType: DatabaseType = .postgresql - ) -> SQLStatementGenerator { - SQLStatementGenerator( + ) throws -> SQLStatementGenerator { + try SQLStatementGenerator( tableName: tableName, columns: columns, primaryKeyColumns: primaryKeyColumns, @@ -54,8 +54,8 @@ struct SQLStatementGeneratorPKRegressionTests { // MARK: - PostgreSQL DELETE with PK @Test("PostgreSQL delete with PK uses $N placeholder and PK-only WHERE") - func testPostgreSQLDeleteWithPK() { - let generator = makeGenerator(databaseType: .postgresql) + func testPostgreSQLDeleteWithPK() throws { + let generator = try makeGenerator(databaseType: .postgresql) let changes = [makeDeleteChange(rowIndex: 0, originalRow: ["1", "John", "john@test.com"])] let statements = generator.generateStatements( @@ -77,8 +77,8 @@ struct SQLStatementGeneratorPKRegressionTests { } @Test("PostgreSQL batch delete with PK uses OR") - func testPostgreSQLBatchDeleteWithPK() { - let generator = makeGenerator(databaseType: .postgresql) + func testPostgreSQLBatchDeleteWithPK() throws { + let generator = try makeGenerator(databaseType: .postgresql) let changes = [ makeDeleteChange(rowIndex: 0, originalRow: ["1", "John", "john@test.com"]), makeDeleteChange(rowIndex: 1, originalRow: ["2", "Jane", "jane@test.com"]) @@ -103,8 +103,8 @@ struct SQLStatementGeneratorPKRegressionTests { // MARK: - MSSQL DELETE with PK @Test("MSSQL delete with PK uses ? placeholder and PK-only WHERE") - func testMSSQLDeleteWithPK() { - let generator = makeGenerator(databaseType: .mssql) + func testMSSQLDeleteWithPK() throws { + let generator = try makeGenerator(databaseType: .mssql) let changes = [makeDeleteChange(rowIndex: 0, originalRow: ["1", "John", "john@test.com"])] let statements = generator.generateStatements( @@ -127,8 +127,8 @@ struct SQLStatementGeneratorPKRegressionTests { // MARK: - ClickHouse DELETE with PK @Test("ClickHouse delete with PK uses ALTER TABLE DELETE") - func testClickHouseDeleteWithPK() { - let generator = makeGenerator(databaseType: .clickhouse) + func testClickHouseDeleteWithPK() throws { + let generator = try makeGenerator(databaseType: .clickhouse) let changes = [makeDeleteChange(rowIndex: 0, originalRow: ["1", "John", "john@test.com"])] let statements = generator.generateStatements( @@ -150,8 +150,8 @@ struct SQLStatementGeneratorPKRegressionTests { // MARK: - UPDATE with PK @Test("PostgreSQL update with PK uses PK-only WHERE") - func testPostgreSQLUpdateWithPK() { - let generator = makeGenerator(databaseType: .postgresql) + func testPostgreSQLUpdateWithPK() throws { + let generator = try makeGenerator(databaseType: .postgresql) let changes = [makeUpdateChange( rowIndex: 0, columnIndex: 1, columnName: "name", oldValue: "John", newValue: "Jane", originalRow: ["1", "John", "john@test.com"] @@ -173,8 +173,8 @@ struct SQLStatementGeneratorPKRegressionTests { } @Test("MSSQL update with PK uses PK-only WHERE") - func testMSSQLUpdateWithPK() { - let generator = makeGenerator(databaseType: .mssql) + func testMSSQLUpdateWithPK() throws { + let generator = try makeGenerator(databaseType: .mssql) let changes = [makeUpdateChange( rowIndex: 0, columnIndex: 1, columnName: "name", oldValue: "John", newValue: "Jane", originalRow: ["1", "John", "john@test.com"] @@ -198,8 +198,8 @@ struct SQLStatementGeneratorPKRegressionTests { // MARK: - Redshift DELETE with PK @Test("Redshift delete with PK uses $N placeholder and PK-only WHERE") - func testRedshiftDeleteWithPK() { - let generator = makeGenerator(databaseType: .redshift) + func testRedshiftDeleteWithPK() throws { + let generator = try makeGenerator(databaseType: .redshift) let changes = [makeDeleteChange(rowIndex: 0, originalRow: ["1", "John", "john@test.com"])] let statements = generator.generateStatements( diff --git a/TableProTests/Core/ChangeTracking/SQLStatementGeneratorParameterStyleTests.swift b/TableProTests/Core/ChangeTracking/SQLStatementGeneratorParameterStyleTests.swift index 9c0c7bc7e..986c44b4c 100644 --- a/TableProTests/Core/ChangeTracking/SQLStatementGeneratorParameterStyleTests.swift +++ b/TableProTests/Core/ChangeTracking/SQLStatementGeneratorParameterStyleTests.swift @@ -21,8 +21,8 @@ struct SQLStatementGeneratorParameterStyleTests { primaryKeyColumns: [String] = ["id"], databaseType: DatabaseType = .mysql, parameterStyle: ParameterStyle? = nil - ) -> SQLStatementGenerator { - SQLStatementGenerator( + ) throws -> SQLStatementGenerator { + try SQLStatementGenerator( tableName: tableName, columns: columns, primaryKeyColumns: primaryKeyColumns, @@ -35,8 +35,8 @@ struct SQLStatementGeneratorParameterStyleTests { // MARK: - Default Parameter Style Tests @Test("PostgreSQL defaults to dollar style") - func testPostgreSQLDefaultsDollar() { - let generator = makeGenerator(databaseType: .postgresql) + func testPostgreSQLDefaultsDollar() throws { + let generator = try makeGenerator(databaseType: .postgresql) let insertedRowData: [Int: [String?]] = [0: ["1", "John", "john@example.com"]] let changes: [RowChange] = [ RowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil) @@ -55,8 +55,8 @@ struct SQLStatementGeneratorParameterStyleTests { } @Test("Redshift defaults to dollar style") - func testRedshiftDefaultsDollar() { - let generator = makeGenerator(databaseType: .redshift) + func testRedshiftDefaultsDollar() throws { + let generator = try makeGenerator(databaseType: .redshift) let insertedRowData: [Int: [String?]] = [0: ["1", "John", "john@example.com"]] let changes: [RowChange] = [ RowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil) @@ -72,8 +72,8 @@ struct SQLStatementGeneratorParameterStyleTests { } @Test("DuckDB defaults to dollar style") - func testDuckDBDefaultsDollar() { - let generator = makeGenerator(databaseType: .duckdb) + func testDuckDBDefaultsDollar() throws { + let generator = try makeGenerator(databaseType: .duckdb) let insertedRowData: [Int: [String?]] = [0: ["1", "John", "john@example.com"]] let changes: [RowChange] = [ RowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil) @@ -89,8 +89,8 @@ struct SQLStatementGeneratorParameterStyleTests { } @Test("MySQL defaults to questionMark style") - func testMySQLDefaultsQuestionMark() { - let generator = makeGenerator(databaseType: .mysql) + func testMySQLDefaultsQuestionMark() throws { + let generator = try makeGenerator(databaseType: .mysql) let insertedRowData: [Int: [String?]] = [0: ["1", "John", "john@example.com"]] let changes: [RowChange] = [ RowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil) @@ -107,8 +107,8 @@ struct SQLStatementGeneratorParameterStyleTests { } @Test("SQLite defaults to questionMark style") - func testSQLiteDefaultsQuestionMark() { - let generator = makeGenerator(databaseType: .sqlite) + func testSQLiteDefaultsQuestionMark() throws { + let generator = try makeGenerator(databaseType: .sqlite) let insertedRowData: [Int: [String?]] = [0: ["1", "John", "john@example.com"]] let changes: [RowChange] = [ RowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil) @@ -125,8 +125,8 @@ struct SQLStatementGeneratorParameterStyleTests { } @Test("MSSQL defaults to questionMark style") - func testMSSQLDefaultsQuestionMark() { - let generator = makeGenerator(databaseType: .mssql) + func testMSSQLDefaultsQuestionMark() throws { + let generator = try makeGenerator(databaseType: .mssql) let insertedRowData: [Int: [String?]] = [0: ["1", "John", "john@example.com"]] let changes: [RowChange] = [ RowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil) @@ -145,8 +145,8 @@ struct SQLStatementGeneratorParameterStyleTests { // MARK: - Explicit Parameter Style Override @Test("Dollar style generates $1, $2 placeholders for INSERT") - func testDollarStyleInsert() { - let generator = makeGenerator(parameterStyle: .dollar) + func testDollarStyleInsert() throws { + let generator = try makeGenerator(parameterStyle: .dollar) let insertedRowData: [Int: [String?]] = [0: ["1", "John", "john@example.com"]] let changes: [RowChange] = [ RowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil) @@ -165,8 +165,8 @@ struct SQLStatementGeneratorParameterStyleTests { } @Test("QuestionMark style generates ? placeholders for INSERT") - func testQuestionMarkStyleInsert() { - let generator = makeGenerator(parameterStyle: .questionMark) + func testQuestionMarkStyleInsert() throws { + let generator = try makeGenerator(parameterStyle: .questionMark) let insertedRowData: [Int: [String?]] = [0: ["1", "John", "john@example.com"]] let changes: [RowChange] = [ RowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil) @@ -184,8 +184,8 @@ struct SQLStatementGeneratorParameterStyleTests { } @Test("Dollar style generates $N placeholders for UPDATE with PK") - func testDollarStyleUpdate() { - let generator = makeGenerator(databaseType: .postgresql, parameterStyle: .dollar) + func testDollarStyleUpdate() throws { + let generator = try makeGenerator(databaseType: .postgresql, parameterStyle: .dollar) let changes: [RowChange] = [ RowChange( rowIndex: 0, @@ -209,8 +209,8 @@ struct SQLStatementGeneratorParameterStyleTests { } @Test("Dollar style generates $N placeholders for DELETE with PK") - func testDollarStyleDelete() { - let generator = makeGenerator(databaseType: .postgresql, parameterStyle: .dollar) + func testDollarStyleDelete() throws { + let generator = try makeGenerator(databaseType: .postgresql, parameterStyle: .dollar) let changes: [RowChange] = [ RowChange( rowIndex: 0, diff --git a/TableProTests/Core/ChangeTracking/SQLStatementGeneratorTests.swift b/TableProTests/Core/ChangeTracking/SQLStatementGeneratorTests.swift index 905e7f0b6..e90ffaa9a 100644 --- a/TableProTests/Core/ChangeTracking/SQLStatementGeneratorTests.swift +++ b/TableProTests/Core/ChangeTracking/SQLStatementGeneratorTests.swift @@ -19,8 +19,8 @@ struct SQLStatementGeneratorTests { columns: [String] = ["id", "name", "email"], primaryKeyColumns: [String] = ["id"], databaseType: DatabaseType = .mysql - ) -> SQLStatementGenerator { - return SQLStatementGenerator( + ) throws -> SQLStatementGenerator { + return try SQLStatementGenerator( tableName: tableName, columns: columns, primaryKeyColumns: primaryKeyColumns, @@ -32,8 +32,8 @@ struct SQLStatementGeneratorTests { // MARK: - INSERT Tests @Test("Simple insert from insertedRowData (MySQL)") - func testSimpleInsertMySQL() { - let generator = makeGenerator() + func testSimpleInsertMySQL() throws { + let generator = try makeGenerator() let insertedRowData: [Int: [String?]] = [ 0: ["1", "John", "john@example.com"] ] @@ -68,8 +68,8 @@ struct SQLStatementGeneratorTests { } @Test("Insert with NULL value") - func testInsertWithNullValue() { - let generator = makeGenerator() + func testInsertWithNullValue() throws { + let generator = try makeGenerator() let insertedRowData: [Int: [String?]] = [ 0: ["1", "John", nil] ] @@ -90,8 +90,8 @@ struct SQLStatementGeneratorTests { } @Test("Insert skips __DEFAULT__ columns") - func testInsertSkipsDefaultColumns() { - let generator = makeGenerator() + func testInsertSkipsDefaultColumns() throws { + let generator = try makeGenerator() let insertedRowData: [Int: [String?]] = [ 0: ["__DEFAULT__", "John", "john@example.com"] ] @@ -115,8 +115,8 @@ struct SQLStatementGeneratorTests { } @Test("Insert with all __DEFAULT__ returns empty") - func testInsertAllDefaultReturnsEmpty() { - let generator = makeGenerator() + func testInsertAllDefaultReturnsEmpty() throws { + let generator = try makeGenerator() let insertedRowData: [Int: [String?]] = [ 0: ["__DEFAULT__", "__DEFAULT__", "__DEFAULT__"] ] @@ -135,8 +135,8 @@ struct SQLStatementGeneratorTests { } @Test("Insert from cellChanges fallback") - func testInsertFromCellChangesFallback() { - let generator = makeGenerator() + func testInsertFromCellChangesFallback() throws { + let generator = try makeGenerator() let changes: [RowChange] = [ RowChange( rowIndex: 0, @@ -162,8 +162,8 @@ struct SQLStatementGeneratorTests { } @Test("Insert with SQL function is inlined") - func testInsertWithSQLFunction() { - let generator = makeGenerator() + func testInsertWithSQLFunction() throws { + let generator = try makeGenerator() let insertedRowData: [Int: [String?]] = [ 0: ["1", "John", "NOW()"] ] @@ -185,8 +185,8 @@ struct SQLStatementGeneratorTests { } @Test("PostgreSQL insert uses $1, $2 placeholders") - func testInsertPostgreSQLPlaceholders() { - let generator = makeGenerator(databaseType: .postgresql) + func testInsertPostgreSQLPlaceholders() throws { + let generator = try makeGenerator(databaseType: .postgresql) let insertedRowData: [Int: [String?]] = [ 0: ["1", "John", "john@example.com"] ] @@ -208,8 +208,8 @@ struct SQLStatementGeneratorTests { } @Test("Table name is quoted with identifier quote") - func testTableNameQuoted() { - let generator = makeGenerator(tableName: "my_table") + func testTableNameQuoted() throws { + let generator = try makeGenerator(tableName: "my_table") let insertedRowData: [Int: [String?]] = [ 0: ["1", "John", "john@example.com"] ] @@ -229,8 +229,8 @@ struct SQLStatementGeneratorTests { } @Test("Column names are quoted") - func testColumnNamesQuoted() { - let generator = makeGenerator(columns: ["user_id", "full_name", "email_address"]) + func testColumnNamesQuoted() throws { + let generator = try makeGenerator(columns: ["user_id", "full_name", "email_address"]) let insertedRowData: [Int: [String?]] = [ 0: ["1", "John", "john@example.com"] ] @@ -253,8 +253,8 @@ struct SQLStatementGeneratorTests { } @Test("Insert multiple rows generates separate statements") - func testInsertMultipleRows() { - let generator = makeGenerator() + func testInsertMultipleRows() throws { + let generator = try makeGenerator() let insertedRowData: [Int: [String?]] = [ 0: ["1", "John", "john@example.com"], 1: ["2", "Jane", "jane@example.com"] @@ -279,8 +279,8 @@ struct SQLStatementGeneratorTests { // MARK: - UPDATE Tests @Test("Simple update (MySQL)") - func testSimpleUpdateMySQL() { - let generator = makeGenerator() + func testSimpleUpdateMySQL() throws { + let generator = try makeGenerator() let changes: [RowChange] = [ RowChange( rowIndex: 0, @@ -312,8 +312,8 @@ struct SQLStatementGeneratorTests { } @Test("Update with multiple columns") - func testUpdateMultipleColumns() { - let generator = makeGenerator() + func testUpdateMultipleColumns() throws { + let generator = try makeGenerator() let changes: [RowChange] = [ RowChange( rowIndex: 0, @@ -341,8 +341,8 @@ struct SQLStatementGeneratorTests { } @Test("Update with NULL new value") - func testUpdateWithNullNewValue() { - let generator = makeGenerator() + func testUpdateWithNullNewValue() throws { + let generator = try makeGenerator() let changes: [RowChange] = [ RowChange( rowIndex: 0, @@ -366,8 +366,8 @@ struct SQLStatementGeneratorTests { } @Test("Update with __DEFAULT__ value") - func testUpdateWithDefaultValue() { - let generator = makeGenerator() + func testUpdateWithDefaultValue() throws { + let generator = try makeGenerator() let changes: [RowChange] = [ RowChange( rowIndex: 0, @@ -393,8 +393,8 @@ struct SQLStatementGeneratorTests { } @Test("Update with SQL function value is inlined") - func testUpdateWithSQLFunction() { - let generator = makeGenerator() + func testUpdateWithSQLFunction() throws { + let generator = try makeGenerator() let changes: [RowChange] = [ RowChange( rowIndex: 0, @@ -420,8 +420,8 @@ struct SQLStatementGeneratorTests { } @Test("MySQL/MariaDB update adds LIMIT 1") - func testUpdateMySQLLimitOne() { - let generator = makeGenerator(databaseType: .mysql) + func testUpdateMySQLLimitOne() throws { + let generator = try makeGenerator(databaseType: .mysql) let changes: [RowChange] = [ RowChange( rowIndex: 0, @@ -445,8 +445,8 @@ struct SQLStatementGeneratorTests { } @Test("PostgreSQL update does NOT add LIMIT 1") - func testUpdatePostgreSQLNoLimit() { - let generator = makeGenerator(databaseType: .postgresql) + func testUpdatePostgreSQLNoLimit() throws { + let generator = try makeGenerator(databaseType: .postgresql) let changes: [RowChange] = [ RowChange( rowIndex: 0, @@ -470,8 +470,8 @@ struct SQLStatementGeneratorTests { } @Test("PostgreSQL update uses $1, $2 placeholders in order") - func testUpdatePostgreSQLPlaceholders() { - let generator = makeGenerator(databaseType: .postgresql) + func testUpdatePostgreSQLPlaceholders() throws { + let generator = try makeGenerator(databaseType: .postgresql) let changes: [RowChange] = [ RowChange( rowIndex: 0, @@ -497,8 +497,8 @@ struct SQLStatementGeneratorTests { } @Test("Update PK value from originalRow") - func testUpdatePKFromOriginalRow() { - let generator = makeGenerator() + func testUpdatePKFromOriginalRow() throws { + let generator = try makeGenerator() let changes: [RowChange] = [ RowChange( rowIndex: 0, @@ -524,8 +524,8 @@ struct SQLStatementGeneratorTests { // MARK: - DELETE Tests @Test("Batch delete with PK (MySQL)") - func testBatchDeleteWithPK() { - let generator = makeGenerator() + func testBatchDeleteWithPK() throws { + let generator = try makeGenerator() let changes: [RowChange] = [ RowChange( rowIndex: 0, @@ -550,8 +550,8 @@ struct SQLStatementGeneratorTests { } @Test("Batch delete with PK, multiple rows") - func testBatchDeleteMultipleRows() { - let generator = makeGenerator() + func testBatchDeleteMultipleRows() throws { + let generator = try makeGenerator() let changes: [RowChange] = [ RowChange(rowIndex: 0, type: .delete, cellChanges: [], originalRow: ["1", "John", "john@example.com"]), RowChange(rowIndex: 1, type: .delete, cellChanges: [], originalRow: ["2", "Jane", "jane@example.com"]) @@ -573,8 +573,8 @@ struct SQLStatementGeneratorTests { } @Test("Individual delete without PK matches all columns") - func testIndividualDeleteNoPK() { - let generator = makeGenerator(primaryKeyColumns: []) + func testIndividualDeleteNoPK() throws { + let generator = try makeGenerator(primaryKeyColumns: []) let changes: [RowChange] = [ RowChange( rowIndex: 0, @@ -601,8 +601,8 @@ struct SQLStatementGeneratorTests { } @Test("Individual delete with NULL column uses IS NULL") - func testIndividualDeleteWithNull() { - let generator = makeGenerator(primaryKeyColumns: []) + func testIndividualDeleteWithNull() throws { + let generator = try makeGenerator(primaryKeyColumns: []) let changes: [RowChange] = [ RowChange( rowIndex: 0, @@ -626,8 +626,8 @@ struct SQLStatementGeneratorTests { } @Test("MySQL/MariaDB individual delete adds LIMIT 1") - func testDeleteMySQLLimitOne() { - let generator = makeGenerator(primaryKeyColumns: [], databaseType: .mysql) + func testDeleteMySQLLimitOne() throws { + let generator = try makeGenerator(primaryKeyColumns: [], databaseType: .mysql) let changes: [RowChange] = [ RowChange( rowIndex: 0, @@ -649,8 +649,8 @@ struct SQLStatementGeneratorTests { } @Test("PostgreSQL delete no LIMIT 1") - func testDeletePostgreSQLNoLimit() { - let generator = makeGenerator(primaryKeyColumns: [], databaseType: .postgresql) + func testDeletePostgreSQLNoLimit() throws { + let generator = try makeGenerator(primaryKeyColumns: [], databaseType: .postgresql) let changes: [RowChange] = [ RowChange( rowIndex: 0, @@ -672,8 +672,8 @@ struct SQLStatementGeneratorTests { } @Test("PostgreSQL delete uses $N placeholders") - func testDeletePostgreSQLPlaceholders() { - let generator = makeGenerator(databaseType: .postgresql) + func testDeletePostgreSQLPlaceholders() throws { + let generator = try makeGenerator(databaseType: .postgresql) let changes: [RowChange] = [ RowChange(rowIndex: 0, type: .delete, cellChanges: [], originalRow: ["1", "John", "john@example.com"]), RowChange(rowIndex: 1, type: .delete, cellChanges: [], originalRow: ["2", "Jane", "jane@example.com"]) @@ -693,8 +693,8 @@ struct SQLStatementGeneratorTests { } @Test("Delete requires originalRow - nil returns nil") - func testDeleteRequiresOriginalRow() { - let generator = makeGenerator() + func testDeleteRequiresOriginalRow() throws { + let generator = try makeGenerator() let changes: [RowChange] = [ RowChange(rowIndex: 0, type: .delete, cellChanges: [], originalRow: nil) ] @@ -710,8 +710,8 @@ struct SQLStatementGeneratorTests { } @Test("Empty changes returns empty result") - func testEmptyChanges() { - let generator = makeGenerator() + func testEmptyChanges() throws { + let generator = try makeGenerator() let statements = generator.generateStatements( from: [], @@ -724,8 +724,8 @@ struct SQLStatementGeneratorTests { } @Test("Mix of insert, update, delete all generated") - func testMixedOperations() { - let generator = makeGenerator() + func testMixedOperations() throws { + let generator = try makeGenerator() let changes: [RowChange] = [ RowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil), RowChange( @@ -755,8 +755,8 @@ struct SQLStatementGeneratorTests { // MARK: - Placeholder Tests @Test("MySQL uses ? for all placeholders") - func testMySQLPlaceholders() { - let generator = makeGenerator(databaseType: .mysql) + func testMySQLPlaceholders() throws { + let generator = try makeGenerator(databaseType: .mysql) let insertedRowData: [Int: [String?]] = [ 0: ["1", "John", "john@example.com"] ] @@ -778,8 +778,8 @@ struct SQLStatementGeneratorTests { } @Test("PostgreSQL uses $1, $2, $3 sequentially") - func testPostgreSQLSequentialPlaceholders() { - let generator = makeGenerator(databaseType: .postgresql) + func testPostgreSQLSequentialPlaceholders() throws { + let generator = try makeGenerator(databaseType: .postgresql) let insertedRowData: [Int: [String?]] = [ 0: ["1", "John", "john@example.com"] ] @@ -803,8 +803,8 @@ struct SQLStatementGeneratorTests { } @Test("SQLite uses ? placeholders") - func testSQLitePlaceholders() { - let generator = makeGenerator(databaseType: .sqlite) + func testSQLitePlaceholders() throws { + let generator = try makeGenerator(databaseType: .sqlite) let insertedRowData: [Int: [String?]] = [ 0: ["1", "John", "john@example.com"] ] @@ -825,8 +825,8 @@ struct SQLStatementGeneratorTests { } @Test("MariaDB uses ? placeholders") - func testMariaDBPlaceholders() { - let generator = makeGenerator(databaseType: .mariadb) + func testMariaDBPlaceholders() throws { + let generator = try makeGenerator(databaseType: .mariadb) let insertedRowData: [Int: [String?]] = [ 0: ["1", "John", "john@example.com"] ] @@ -849,8 +849,8 @@ struct SQLStatementGeneratorTests { // MARK: - Safety Tests @Test("Insert only processes rows in insertedRowIndices set") - func testInsertOnlyProcessesInsertedRows() { - let generator = makeGenerator() + func testInsertOnlyProcessesInsertedRows() throws { + let generator = try makeGenerator() let insertedRowData: [Int: [String?]] = [ 0: ["1", "John", "john@example.com"], 1: ["2", "Jane", "jane@example.com"] @@ -872,8 +872,8 @@ struct SQLStatementGeneratorTests { } @Test("Delete only processes rows in deletedRowIndices set") - func testDeleteOnlyProcessesDeletedRows() { - let generator = makeGenerator() + func testDeleteOnlyProcessesDeletedRows() throws { + let generator = try makeGenerator() let changes: [RowChange] = [ RowChange(rowIndex: 0, type: .delete, cellChanges: [], originalRow: ["1", "John", "john@example.com"]), RowChange(rowIndex: 1, type: .delete, cellChanges: [], originalRow: ["2", "Jane", "jane@example.com"]) @@ -892,8 +892,8 @@ struct SQLStatementGeneratorTests { } @Test("Row not in insertedRowIndices is skipped") - func testRowNotInInsertedRowIndicesSkipped() { - let generator = makeGenerator() + func testRowNotInInsertedRowIndicesSkipped() throws { + let generator = try makeGenerator() let insertedRowData: [Int: [String?]] = [ 0: ["1", "John", "john@example.com"] ] @@ -912,8 +912,8 @@ struct SQLStatementGeneratorTests { } @Test("Row not in deletedRowIndices is skipped") - func testRowNotInDeletedRowIndicesSkipped() { - let generator = makeGenerator() + func testRowNotInDeletedRowIndicesSkipped() throws { + let generator = try makeGenerator() let changes: [RowChange] = [ RowChange(rowIndex: 0, type: .delete, cellChanges: [], originalRow: ["1", "John", "john@example.com"]) ] @@ -931,8 +931,8 @@ struct SQLStatementGeneratorTests { // MARK: - Integration Tests @Test("Full workflow: insert + update + delete in one call") - func testFullWorkflowIntegration() { - let generator = makeGenerator() + func testFullWorkflowIntegration() throws { + let generator = try makeGenerator() let changes: [RowChange] = [ RowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil), RowChange( @@ -963,8 +963,8 @@ struct SQLStatementGeneratorTests { } @Test("Verify parameter order matches placeholder order") - func testParameterOrderMatchesPlaceholders() { - let generator = makeGenerator(databaseType: .postgresql) + func testParameterOrderMatchesPlaceholders() throws { + let generator = try makeGenerator(databaseType: .postgresql) let changes: [RowChange] = [ RowChange( rowIndex: 0, @@ -1003,8 +1003,8 @@ struct SQLStatementGeneratorTests { // MARK: - Redshift Tests @Test("Redshift insert uses $1, $2 placeholders") - func testInsertRedshiftPlaceholders() { - let generator = makeGenerator(databaseType: .redshift) + func testInsertRedshiftPlaceholders() throws { + let generator = try makeGenerator(databaseType: .redshift) let insertedRowData: [Int: [String?]] = [ 0: ["1", "John", "john@example.com"] ] @@ -1028,8 +1028,8 @@ struct SQLStatementGeneratorTests { } @Test("Redshift insert uses double-quote identifier quoting") - func testInsertRedshiftQuoting() { - let generator = makeGenerator(databaseType: .redshift) + func testInsertRedshiftQuoting() throws { + let generator = try makeGenerator(databaseType: .redshift) let insertedRowData: [Int: [String?]] = [ 0: ["1", "John", "john@example.com"] ] @@ -1054,8 +1054,8 @@ struct SQLStatementGeneratorTests { } @Test("Redshift update uses $1, $2 placeholders in order") - func testUpdateRedshiftPlaceholders() { - let generator = makeGenerator(databaseType: .redshift) + func testUpdateRedshiftPlaceholders() throws { + let generator = try makeGenerator(databaseType: .redshift) let changes: [RowChange] = [ RowChange( rowIndex: 0, @@ -1082,8 +1082,8 @@ struct SQLStatementGeneratorTests { } @Test("Redshift update does NOT add LIMIT 1") - func testUpdateRedshiftNoLimit() { - let generator = makeGenerator(databaseType: .redshift) + func testUpdateRedshiftNoLimit() throws { + let generator = try makeGenerator(databaseType: .redshift) let changes: [RowChange] = [ RowChange( rowIndex: 0, @@ -1107,8 +1107,8 @@ struct SQLStatementGeneratorTests { } @Test("Redshift delete uses $N placeholders") - func testDeleteRedshiftPlaceholders() { - let generator = makeGenerator(databaseType: .redshift) + func testDeleteRedshiftPlaceholders() throws { + let generator = try makeGenerator(databaseType: .redshift) let changes: [RowChange] = [ RowChange(rowIndex: 0, type: .delete, cellChanges: [], originalRow: ["1", "John", "john@example.com"]), RowChange(rowIndex: 1, type: .delete, cellChanges: [], originalRow: ["2", "Jane", "jane@example.com"]) @@ -1129,8 +1129,8 @@ struct SQLStatementGeneratorTests { } @Test("Redshift delete no LIMIT 1") - func testDeleteRedshiftNoLimit() { - let generator = makeGenerator(primaryKeyColumns: [], databaseType: .redshift) + func testDeleteRedshiftNoLimit() throws { + let generator = try makeGenerator(primaryKeyColumns: [], databaseType: .redshift) let changes: [RowChange] = [ RowChange( rowIndex: 0, @@ -1152,8 +1152,8 @@ struct SQLStatementGeneratorTests { } @Test("Redshift uses $1, $2, $3 sequentially for insert") - func testRedshiftSequentialPlaceholders() { - let generator = makeGenerator(databaseType: .redshift) + func testRedshiftSequentialPlaceholders() throws { + let generator = try makeGenerator(databaseType: .redshift) let insertedRowData: [Int: [String?]] = [ 0: ["1", "John", "john@example.com"] ] @@ -1177,8 +1177,8 @@ struct SQLStatementGeneratorTests { } @Test("Redshift parameter order matches placeholder order") - func testRedshiftParameterOrder() { - let generator = makeGenerator(databaseType: .redshift) + func testRedshiftParameterOrder() throws { + let generator = try makeGenerator(databaseType: .redshift) let changes: [RowChange] = [ RowChange( rowIndex: 0, @@ -1213,8 +1213,8 @@ struct SQLStatementGeneratorTests { // MARK: - Reserved Keyword Column Name Regression (GH-373) @Test("UPDATE quotes reserved keyword column names in MySQL") - func testUpdateQuotesReservedKeywordColumnMySQL() { - let generator = makeGenerator( + func testUpdateQuotesReservedKeywordColumnMySQL() throws { + let generator = try makeGenerator( tableName: "connections", columns: ["id", "database", "table", "order"], primaryKeyColumns: ["id"] @@ -1245,8 +1245,8 @@ struct SQLStatementGeneratorTests { } @Test("INSERT quotes reserved keyword column names in MySQL") - func testInsertQuotesReservedKeywordColumnMySQL() { - let generator = makeGenerator( + func testInsertQuotesReservedKeywordColumnMySQL() throws { + let generator = try makeGenerator( tableName: "connections", columns: ["id", "database", "order"], primaryKeyColumns: ["id"] @@ -1274,8 +1274,8 @@ struct SQLStatementGeneratorTests { } @Test("DELETE quotes reserved keyword column names in MySQL (no PK)") - func testDeleteQuotesReservedKeywordColumnMySQL() { - let generator = makeGenerator( + func testDeleteQuotesReservedKeywordColumnMySQL() throws { + let generator = try makeGenerator( tableName: "connections", columns: ["id", "database", "select"], primaryKeyColumns: [] @@ -1303,8 +1303,8 @@ struct SQLStatementGeneratorTests { } @Test("UPDATE quotes reserved keyword column names in PostgreSQL") - func testUpdateQuotesReservedKeywordColumnPostgreSQL() { - let generator = makeGenerator( + func testUpdateQuotesReservedKeywordColumnPostgreSQL() throws { + let generator = try makeGenerator( tableName: "connections", columns: ["id", "database", "order"], primaryKeyColumns: ["id"], diff --git a/TableProTests/Core/Utilities/SQLRowToStatementConverterTests.swift b/TableProTests/Core/Utilities/SQLRowToStatementConverterTests.swift index 20fe3237b..bf49ec4d3 100644 --- a/TableProTests/Core/Utilities/SQLRowToStatementConverterTests.swift +++ b/TableProTests/Core/Utilities/SQLRowToStatementConverterTests.swift @@ -59,8 +59,8 @@ struct SQLRowToStatementConverterTests { primaryKeyColumn: String? = "id", databaseType: DatabaseType = .mysql, dialect: SQLDialectDescriptor? = Self.mysqlDialect - ) -> SQLRowToStatementConverter { - SQLRowToStatementConverter( + ) throws -> SQLRowToStatementConverter { + try SQLRowToStatementConverter( tableName: tableName, columns: columns, primaryKeyColumn: primaryKeyColumn, @@ -72,15 +72,15 @@ struct SQLRowToStatementConverterTests { // MARK: - INSERT Generation @Test("Single row produces one INSERT statement") - func insertSingleRow() { - let converter = makeConverter() + func insertSingleRow() throws { + let converter = try makeConverter() let result = converter.generateInserts(rows: [["1", "Alice", "alice@example.com"]]) #expect(result == "INSERT INTO `users` (`id`, `name`, `email`) VALUES ('1', 'Alice', 'alice@example.com');") } @Test("Multiple rows are joined by newlines") - func insertMultipleRows() { - let converter = makeConverter() + func insertMultipleRows() throws { + let converter = try makeConverter() let rows: [[String?]] = [ ["1", "Alice", "alice@example.com"], ["2", "Bob", "bob@example.com"] @@ -93,22 +93,22 @@ struct SQLRowToStatementConverterTests { } @Test("NULL values render as unquoted NULL") - func insertNullValues() { - let converter = makeConverter() + func insertNullValues() throws { + let converter = try makeConverter() let result = converter.generateInserts(rows: [["1", nil, nil]]) #expect(result == "INSERT INTO `users` (`id`, `name`, `email`) VALUES ('1', NULL, NULL);") } @Test("Empty strings render as empty quoted string") - func insertEmptyStrings() { - let converter = makeConverter() + func insertEmptyStrings() throws { + let converter = try makeConverter() let result = converter.generateInserts(rows: [["1", "", ""]]) #expect(result == "INSERT INTO `users` (`id`, `name`, `email`) VALUES ('1', '', '');") } @Test("Single quotes in data are escaped as double single-quotes") - func insertSpecialCharactersSingleQuotes() { - let converter = makeConverter() + func insertSpecialCharactersSingleQuotes() throws { + let converter = try makeConverter() let result = converter.generateInserts(rows: [["1", "O'Brien", "o'brien@example.com"]]) #expect(result == "INSERT INTO `users` (`id`, `name`, `email`) VALUES ('1', 'O''Brien', 'o''brien@example.com');") } @@ -116,29 +116,29 @@ struct SQLRowToStatementConverterTests { // MARK: - UPDATE Generation @Test("UPDATE with primary key excludes PK from SET and uses PK in WHERE") - func updateWithPrimaryKey() { - let converter = makeConverter() + func updateWithPrimaryKey() throws { + let converter = try makeConverter() let result = converter.generateUpdates(rows: [["1", "Alice", "alice@example.com"]]) #expect(result == "UPDATE `users` SET `name` = 'Alice', `email` = 'alice@example.com' WHERE `id` = '1';") } @Test("UPDATE without primary key uses all columns in SET and WHERE") - func updateWithoutPrimaryKey() { - let converter = makeConverter(primaryKeyColumn: nil) + func updateWithoutPrimaryKey() throws { + let converter = try makeConverter(primaryKeyColumn: nil) let result = converter.generateUpdates(rows: [["1", "Alice", "alice@example.com"]]) #expect(result == "UPDATE `users` SET `id` = '1', `name` = 'Alice', `email` = 'alice@example.com' WHERE `id` = '1' AND `name` = 'Alice' AND `email` = 'alice@example.com';") } @Test("UPDATE without PK uses IS NULL in WHERE clause for NULL values") - func updateNullValuesInWhereClauseNoPK() { - let converter = makeConverter(primaryKeyColumn: nil) + func updateNullValuesInWhereClauseNoPK() throws { + let converter = try makeConverter(primaryKeyColumn: nil) let result = converter.generateUpdates(rows: [["1", nil, "alice@example.com"]]) #expect(result == "UPDATE `users` SET `id` = '1', `name` = NULL, `email` = 'alice@example.com' WHERE `id` = '1' AND `name` IS NULL AND `email` = 'alice@example.com';") } @Test("UPDATE with PK uses IS NULL in WHERE when PK value is NULL") - func updateNullPrimaryKeyValue() { - let converter = makeConverter() + func updateNullPrimaryKeyValue() throws { + let converter = try makeConverter() let result = converter.generateUpdates(rows: [[nil, "Alice", "alice@example.com"]]) #expect(result == "UPDATE `users` SET `name` = 'Alice', `email` = 'alice@example.com' WHERE `id` IS NULL;") } @@ -146,36 +146,36 @@ struct SQLRowToStatementConverterTests { // MARK: - Database-Specific Quoting @Test("ClickHouse fallback uses standard UPDATE syntax (plugin handles ALTER TABLE at runtime)") - func clickhouseFallbackUsesStandardUpdate() { - let converter = makeConverter(databaseType: .clickhouse, dialect: Self.clickhouseDialect) + func clickhouseFallbackUsesStandardUpdate() throws { + let converter = try makeConverter(databaseType: .clickhouse, dialect: Self.clickhouseDialect) let result = converter.generateUpdates(rows: [["1", "Alice", "alice@example.com"]]) #expect(result == "UPDATE `users` SET `name` = 'Alice', `email` = 'alice@example.com' WHERE `id` = '1';") } @Test("MSSQL uses bracket quoting") - func mssqlUsesBracketQuoting() { - let converter = makeConverter(databaseType: .mssql, dialect: Self.mssqlDialect) + func mssqlUsesBracketQuoting() throws { + let converter = try makeConverter(databaseType: .mssql, dialect: Self.mssqlDialect) let result = converter.generateInserts(rows: [["1", "Alice", "alice@example.com"]]) #expect(result == "INSERT INTO [users] ([id], [name], [email]) VALUES ('1', 'Alice', 'alice@example.com');") } @Test("PostgreSQL uses double-quote quoting") - func postgresqlUsesDoubleQuoteQuoting() { - let converter = makeConverter(databaseType: .postgresql, dialect: Self.postgresDialect) + func postgresqlUsesDoubleQuoteQuoting() throws { + let converter = try makeConverter(databaseType: .postgresql, dialect: Self.postgresDialect) let result = converter.generateInserts(rows: [["1", "Alice", "alice@example.com"]]) #expect(result == "INSERT INTO \"users\" (\"id\", \"name\", \"email\") VALUES ('1', 'Alice', 'alice@example.com');") } @Test("MySQL uses backtick quoting") - func mysqlUsesBacktickQuoting() { - let converter = makeConverter(databaseType: .mysql) + func mysqlUsesBacktickQuoting() throws { + let converter = try makeConverter(databaseType: .mysql) let result = converter.generateInserts(rows: [["1", "Alice", "alice@example.com"]]) #expect(result == "INSERT INTO `users` (`id`, `name`, `email`) VALUES ('1', 'Alice', 'alice@example.com');") } @Test("DuckDB uses double-quote quoting and standard UPDATE syntax") - func duckdbUsesDoubleQuoteAndStandardUpdate() { - let converter = makeConverter(databaseType: .duckdb, dialect: Self.duckdbDialect) + func duckdbUsesDoubleQuoteAndStandardUpdate() throws { + let converter = try makeConverter(databaseType: .duckdb, dialect: Self.duckdbDialect) let insert = converter.generateInserts(rows: [["1", "Alice", "alice@example.com"]]) #expect(insert == "INSERT INTO \"users\" (\"id\", \"name\", \"email\") VALUES ('1', 'Alice', 'alice@example.com');") let update = converter.generateUpdates(rows: [["1", "Alice", "alice@example.com"]]) @@ -183,22 +183,22 @@ struct SQLRowToStatementConverterTests { } @Test("MySQL escapes backslashes in values") - func mysqlBackslashEscaping() { - let converter = makeConverter(databaseType: .mysql) + func mysqlBackslashEscaping() throws { + let converter = try makeConverter(databaseType: .mysql) let result = converter.generateInserts(rows: [["1", "C:\\Users\\test", "a@b.com"]]) #expect(result == "INSERT INTO `users` (`id`, `name`, `email`) VALUES ('1', 'C:\\\\Users\\\\test', 'a@b.com');") } @Test("PostgreSQL does not escape backslashes") - func postgresqlNoBackslashEscaping() { - let converter = makeConverter(databaseType: .postgresql, dialect: Self.postgresDialect) + func postgresqlNoBackslashEscaping() throws { + let converter = try makeConverter(databaseType: .postgresql, dialect: Self.postgresDialect) let result = converter.generateInserts(rows: [["1", "C:\\Users\\test", "a@b.com"]]) #expect(result == "INSERT INTO \"users\" (\"id\", \"name\", \"email\") VALUES ('1', 'C:\\Users\\test', 'a@b.com');") } @Test("UPDATE falls back to all-column WHERE when PK not in columns") - func updatePkNotInColumnsFallsBack() { - let converter = makeConverter( + func updatePkNotInColumnsFallsBack() throws { + let converter = try makeConverter( columns: ["name", "email"], primaryKeyColumn: "id", databaseType: .mysql @@ -210,15 +210,15 @@ struct SQLRowToStatementConverterTests { // MARK: - Edge Cases @Test("Empty rows input returns empty string") - func emptyRowsReturnsEmptyString() { - let converter = makeConverter() + func emptyRowsReturnsEmptyString() throws { + let converter = try makeConverter() #expect(converter.generateInserts(rows: []) == "") #expect(converter.generateUpdates(rows: []) == "") } @Test("Row cap at 50,000 — 50,001 rows produces exactly 50,000 lines") - func rowCapAt50k() { - let converter = makeConverter( + func rowCapAt50k() throws { + let converter = try makeConverter( columns: ["id", "name"], primaryKeyColumn: "id" ) diff --git a/TableProTests/Models/EditorTabPayloadTests.swift b/TableProTests/Models/EditorTabPayloadTests.swift index d0f5b4202..a06b855da 100644 --- a/TableProTests/Models/EditorTabPayloadTests.swift +++ b/TableProTests/Models/EditorTabPayloadTests.swift @@ -126,9 +126,9 @@ struct EditorTabPayloadTests { @Test("Init from QueryTab maps fields correctly") @MainActor - func initFromQueryTab() { + func initFromQueryTab() throws { let tabManager = QueryTabManager() - tabManager.addTableTab(tableName: "users", databaseType: .mysql, databaseName: "mydb") + try tabManager.addTableTab(tableName: "users", databaseType: .mysql, databaseName: "mydb") let tab = tabManager.tabs.first! let connectionId = UUID() let payload = EditorTabPayload(from: tab, connectionId: connectionId) diff --git a/TableProTests/Models/PreviewTabTests.swift b/TableProTests/Models/PreviewTabTests.swift index 74c128cc3..8fcf8a11f 100644 --- a/TableProTests/Models/PreviewTabTests.swift +++ b/TableProTests/Models/PreviewTabTests.swift @@ -38,9 +38,9 @@ struct PreviewTabTests { @Test("Preview table tab can be added via addPreviewTableTab") @MainActor - func addPreviewTableTab() { + func addPreviewTableTab() throws { let manager = QueryTabManager() - manager.addPreviewTableTab(tableName: "users", databaseType: .mysql, databaseName: "mydb") + try manager.addPreviewTableTab(tableName: "users", databaseType: .mysql, databaseName: "mydb") #expect(manager.tabs.count == 1) #expect(manager.selectedTab?.isPreview == true) #expect(manager.selectedTab?.tableContext.tableName == "users") @@ -48,10 +48,10 @@ struct PreviewTabTests { @Test("replaceTabContent can set isPreview flag") @MainActor - func replaceTabContentSetsPreview() { + func replaceTabContentSetsPreview() throws { let manager = QueryTabManager() - manager.addPreviewTableTab(tableName: "users", databaseType: .mysql, databaseName: "mydb") - let replaced = manager.replaceTabContent( + try manager.addPreviewTableTab(tableName: "users", databaseType: .mysql, databaseName: "mydb") + let replaced = try manager.replaceTabContent( tableName: "orders", databaseType: .mysql, databaseName: "mydb", @@ -64,10 +64,10 @@ struct PreviewTabTests { @Test("replaceTabContent defaults to non-preview") @MainActor - func replaceTabContentDefaultsNonPreview() { + func replaceTabContentDefaultsNonPreview() throws { let manager = QueryTabManager() - manager.addPreviewTableTab(tableName: "users", databaseType: .mysql, databaseName: "mydb") - let replaced = manager.replaceTabContent( + try manager.addPreviewTableTab(tableName: "users", databaseType: .mysql, databaseName: "mydb") + let replaced = try manager.replaceTabContent( tableName: "orders", databaseType: .mysql, databaseName: "mydb" diff --git a/TableProTests/Models/Query/QueryTabManagerTests.swift b/TableProTests/Models/Query/QueryTabManagerTests.swift index 08421836f..9987fc167 100644 --- a/TableProTests/Models/Query/QueryTabManagerTests.swift +++ b/TableProTests/Models/Query/QueryTabManagerTests.swift @@ -23,9 +23,9 @@ struct QueryTabManagerSelectedTabAndIndexTests { } @Test("returns the selected tab and its index after addTableTab") - func returnsSelectedTabAfterAdd() { + func returnsSelectedTabAfterAdd() throws { let manager = QueryTabManager() - manager.addTableTab(tableName: "users") + try manager.addTableTab(tableName: "users") let result = manager.selectedTabAndIndex #expect(result?.index == 0) @@ -33,9 +33,9 @@ struct QueryTabManagerSelectedTabAndIndexTests { } @Test("returns nil when selectedTabId points to a removed tab") - func nilWhenSelectionIsStale() { + func nilWhenSelectionIsStale() throws { let manager = QueryTabManager() - manager.addTableTab(tableName: "users") + try manager.addTableTab(tableName: "users") let staleId = manager.tabs[0].id manager.tabs.removeAll() @@ -45,10 +45,10 @@ struct QueryTabManagerSelectedTabAndIndexTests { } @Test("returns the correct (tab, index) pair after switching tabs") - func returnsCorrectPairAfterSwitch() { + func returnsCorrectPairAfterSwitch() throws { let manager = QueryTabManager() - manager.addTableTab(tableName: "users") - manager.addTableTab(tableName: "orders") + try manager.addTableTab(tableName: "users") + try manager.addTableTab(tableName: "orders") let firstId = manager.tabs[0].id manager.selectedTabId = firstId diff --git a/TableProTests/Models/Query/TabStructureVersionTests.swift b/TableProTests/Models/Query/TabStructureVersionTests.swift index c01323302..25a009fb5 100644 --- a/TableProTests/Models/Query/TabStructureVersionTests.swift +++ b/TableProTests/Models/Query/TabStructureVersionTests.swift @@ -28,14 +28,14 @@ struct TabStructureVersionTests { } @Test("addTableTab(...) for a new table bumps once; activating an existing table does NOT bump") - func addTableTabBumpsOnceAndIdempotent() { + func addTableTabBumpsOnceAndIdempotent() throws { let manager = QueryTabManager() - manager.addTableTab(tableName: "users") + try manager.addTableTab(tableName: "users") let afterFirstAdd = manager.tabStructureVersion #expect(afterFirstAdd == 1) - manager.addTableTab(tableName: "users") + try manager.addTableTab(tableName: "users") #expect(manager.tabStructureVersion == afterFirstAdd) } @@ -67,21 +67,21 @@ struct TabStructureVersionTests { } @Test("replaceTabContent(...) bumps the version (in-place mutation, same id)") - func replaceTabContentBumps() { + func replaceTabContentBumps() throws { let manager = QueryTabManager() - manager.addTableTab(tableName: "users") + try manager.addTableTab(tableName: "users") let beforeReplace = manager.tabStructureVersion - let didReplace = manager.replaceTabContent(tableName: "orders") + let didReplace = try manager.replaceTabContent(tableName: "orders") #expect(didReplace) #expect(manager.tabStructureVersion == beforeReplace + 1) } @Test("markTabRenamed bumps when the tab id exists; no-op when it does not") - func markTabRenamedBumpsOnlyForKnownIds() { + func markTabRenamedBumpsOnlyForKnownIds() throws { let manager = QueryTabManager() - manager.addTableTab(tableName: "users") + try manager.addTableTab(tableName: "users") let knownId = manager.tabs[0].id let before = manager.tabStructureVersion @@ -94,9 +94,9 @@ struct TabStructureVersionTests { } @Test("updateTab(...) does NOT bump the version (content-only update)") - func updateTabDoesNotBump() { + func updateTabDoesNotBump() throws { let manager = QueryTabManager() - manager.addTableTab(tableName: "users") + try manager.addTableTab(tableName: "users") var tab = manager.tabs[0] let before = manager.tabStructureVersion @@ -107,9 +107,9 @@ struct TabStructureVersionTests { } @Test("Mutating a tab's content directly via tabs[i] does NOT bump (id array unchanged)") - func directContentMutationDoesNotBump() { + func directContentMutationDoesNotBump() throws { let manager = QueryTabManager() - manager.addTableTab(tableName: "users") + try manager.addTableTab(tableName: "users") let before = manager.tabStructureVersion manager.tabs[0].content.query = "SELECT * FROM users WHERE id = 1" @@ -118,10 +118,10 @@ struct TabStructureVersionTests { } @Test("Removing a tab via tabs.remove(at:) bumps via the didSet") - func tabsRemovalBumps() { + func tabsRemovalBumps() throws { let manager = QueryTabManager() - manager.addTableTab(tableName: "users") - manager.addTableTab(tableName: "orders") + try manager.addTableTab(tableName: "users") + try manager.addTableTab(tableName: "orders") let before = manager.tabStructureVersion manager.tabs.remove(at: 0) @@ -130,11 +130,11 @@ struct TabStructureVersionTests { } @Test("Drag-reordering tabs (id array reordered) bumps via the didSet") - func tabsReorderBumps() { + func tabsReorderBumps() throws { let manager = QueryTabManager() - manager.addTableTab(tableName: "users") - manager.addTableTab(tableName: "orders") - manager.addTableTab(tableName: "products") + try manager.addTableTab(tableName: "users") + try manager.addTableTab(tableName: "orders") + try manager.addTableTab(tableName: "products") let before = manager.tabStructureVersion manager.tabs.swapAt(0, 2) diff --git a/TableProTests/Views/Main/CoordinatorEditorLoadTests.swift b/TableProTests/Views/Main/CoordinatorEditorLoadTests.swift index 450c507ae..eae248e64 100644 --- a/TableProTests/Views/Main/CoordinatorEditorLoadTests.swift +++ b/TableProTests/Views/Main/CoordinatorEditorLoadTests.swift @@ -66,11 +66,11 @@ struct CoordinatorEditorLoadTests { @Test("loadQueryIntoEditor does not modify table tab") @MainActor - func loadQuerySkipsTableTab() { + func loadQuerySkipsTableTab() throws { let (coordinator, tabManager) = makeCoordinator() defer { coordinator.teardown() } - tabManager.addTableTab(tableName: "users") + try tabManager.addTableTab(tableName: "users") let originalQuery = tabManager.tabs[0].content.query // Falls through to WindowOpener path; table tab unchanged @@ -153,11 +153,11 @@ struct CoordinatorEditorLoadTests { @Test("insertQueryFromAI does not modify table tab") @MainActor - func insertAiSkipsTableTab() { + func insertAiSkipsTableTab() throws { let (coordinator, tabManager) = makeCoordinator() defer { coordinator.teardown() } - tabManager.addTableTab(tableName: "orders") + try tabManager.addTableTab(tableName: "orders") let originalQuery = tabManager.tabs[0].content.query coordinator.insertQueryFromAI("SELECT * FROM orders") diff --git a/TableProTests/Views/Main/EvictionTests.swift b/TableProTests/Views/Main/EvictionTests.swift index bf6159cd8..5bb20c441 100644 --- a/TableProTests/Views/Main/EvictionTests.swift +++ b/TableProTests/Views/Main/EvictionTests.swift @@ -34,7 +34,7 @@ struct EvictionTests { tabManager: QueryTabManager, tableName: String = "users" ) { - tabManager.addTableTab(tableName: tableName) + try tabManager.addTableTab(tableName: tableName) guard let index = tabManager.selectedTabIndex else { return } let rows = TestFixtures.makeRows(count: 10) let tabId = tabManager.tabs[index].id diff --git a/TableProTests/Views/Main/MultiConnectionNavigationTests.swift b/TableProTests/Views/Main/MultiConnectionNavigationTests.swift index 22db979ba..d5b31141b 100644 --- a/TableProTests/Views/Main/MultiConnectionNavigationTests.swift +++ b/TableProTests/Views/Main/MultiConnectionNavigationTests.swift @@ -43,11 +43,11 @@ struct MultiConnectionNavigationTests { @Test("Fast path sets showStructure on the existing active tab") @MainActor - func fastPathSetsShowStructure() { + func fastPathSetsShowStructure() throws { let (coordinator, tabManager) = makeCoordinator(database: "db_a") defer { coordinator.teardown() } - tabManager.addTableTab(tableName: "users", databaseType: .mysql, databaseName: "db_a") + try tabManager.addTableTab(tableName: "users", databaseType: .mysql, databaseName: "db_a") guard let idx = tabManager.selectedTabIndex else { Issue.record("Expected selected tab index") return @@ -136,9 +136,9 @@ struct MultiConnectionNavigationTests { @Test("resolve returns skip for mysql when same table is active") @MainActor - func resolveSkipForMysql() { + func resolveSkipForMysql() throws { let manager = QueryTabManager() - manager.addTableTab(tableName: "users", databaseType: .mysql, databaseName: "mydb") + try manager.addTableTab(tableName: "users", databaseType: .mysql, databaseName: "mydb") let result = SidebarNavigationResult.resolve( clickedTableName: "users", currentTabTableName: manager.selectedTab?.tableContext.tableName, @@ -149,9 +149,9 @@ struct MultiConnectionNavigationTests { @Test("resolve returns skip for postgresql when same table is active") @MainActor - func resolveSkipForPostgresql() { + func resolveSkipForPostgresql() throws { let manager = QueryTabManager() - manager.addTableTab(tableName: "accounts", databaseType: .postgresql, databaseName: "pgdb") + try manager.addTableTab(tableName: "accounts", databaseType: .postgresql, databaseName: "pgdb") let result = SidebarNavigationResult.resolve( clickedTableName: "accounts", currentTabTableName: manager.selectedTab?.tableContext.tableName, @@ -162,9 +162,9 @@ struct MultiConnectionNavigationTests { @Test("resolve returns skip for sqlite when same table is active") @MainActor - func resolveSkipForSqlite() { + func resolveSkipForSqlite() throws { let manager = QueryTabManager() - manager.addTableTab(tableName: "items", databaseType: .sqlite, databaseName: "local.db") + try manager.addTableTab(tableName: "items", databaseType: .sqlite, databaseName: "local.db") let result = SidebarNavigationResult.resolve( clickedTableName: "items", currentTabTableName: manager.selectedTab?.tableContext.tableName, @@ -209,7 +209,7 @@ struct MultiConnectionNavigationTests { @Test("Two coordinators with different connections have independent tab managers") @MainActor - func twoCoordinatorsHaveIndependentTabManagers() { + func twoCoordinatorsHaveIndependentTabManagers() throws { let (coordinatorA, tabManagerA) = makeCoordinator(name: "ConnA", database: "db_a") let (coordinatorB, tabManagerB) = makeCoordinator(name: "ConnB", database: "db_b") defer { @@ -217,9 +217,9 @@ struct MultiConnectionNavigationTests { coordinatorB.teardown() } - tabManagerA.addTableTab(tableName: "users", databaseType: .mysql, databaseName: "db_a") - tabManagerB.addTableTab(tableName: "orders", databaseType: .mysql, databaseName: "db_b") - tabManagerB.addTableTab(tableName: "products", databaseType: .mysql, databaseName: "db_b") + try tabManagerA.addTableTab(tableName: "users", databaseType: .mysql, databaseName: "db_a") + try tabManagerB.addTableTab(tableName: "orders", databaseType: .mysql, databaseName: "db_b") + try tabManagerB.addTableTab(tableName: "products", databaseType: .mysql, databaseName: "db_b") #expect(tabManagerA.tabs.count == 1) #expect(tabManagerB.tabs.count == 2) @@ -229,7 +229,7 @@ struct MultiConnectionNavigationTests { @Test("openTableTab on coordinator A does not affect coordinator B's tabs") @MainActor - func openTableTabOnADoesNotAffectB() { + func openTableTabOnADoesNotAffectB() throws { let (coordinatorA, tabManagerA) = makeCoordinator(name: "ConnA", database: "db_a") let (coordinatorB, tabManagerB) = makeCoordinator(name: "ConnB", database: "db_b") defer { @@ -237,7 +237,7 @@ struct MultiConnectionNavigationTests { coordinatorB.teardown() } - tabManagerB.addTableTab(tableName: "orders", databaseType: .mysql, databaseName: "db_b") + try tabManagerB.addTableTab(tableName: "orders", databaseType: .mysql, databaseName: "db_b") let tabCountBefore = tabManagerB.tabs.count coordinatorA.openTableTab("users") diff --git a/TableProTests/Views/Main/RowOperationsDispatchTests.swift b/TableProTests/Views/Main/RowOperationsDispatchTests.swift index f61fdcbf5..17d938e4c 100644 --- a/TableProTests/Views/Main/RowOperationsDispatchTests.swift +++ b/TableProTests/Views/Main/RowOperationsDispatchTests.swift @@ -64,7 +64,7 @@ struct RowOperationsDispatchTests { delegate.tableViewCoordinator = fake coordinator.dataTabDelegate = delegate - tabManager.addTableTab(tableName: "users") + try tabManager.addTableTab(tableName: "users") let tabIndex = tabManager.selectedTabIndex ?? 0 tabManager.tabs[tabIndex].tableContext.isEditable = true let tabId = tabManager.tabs[tabIndex].id diff --git a/TableProTests/Views/Main/SortCacheInvalidationTests.swift b/TableProTests/Views/Main/SortCacheInvalidationTests.swift index 69ebcd2ce..955f3e104 100644 --- a/TableProTests/Views/Main/SortCacheInvalidationTests.swift +++ b/TableProTests/Views/Main/SortCacheInvalidationTests.swift @@ -26,7 +26,7 @@ struct SortCacheInvalidationTests { columnVisibilityManager: ColumnVisibilityManager(), toolbarState: ConnectionToolbarState() ) - tabManager.addTableTab(tableName: "users") + try tabManager.addTableTab(tableName: "users") let tabIndex = tabManager.selectedTabIndex ?? 0 tabManager.tabs[tabIndex].tableContext.isEditable = true let tabId = tabManager.tabs[tabIndex].id diff --git a/TableProTests/Views/Main/TableRowsMutationTests.swift b/TableProTests/Views/Main/TableRowsMutationTests.swift index 1816bba88..c0272162a 100644 --- a/TableProTests/Views/Main/TableRowsMutationTests.swift +++ b/TableProTests/Views/Main/TableRowsMutationTests.swift @@ -77,9 +77,9 @@ struct TableRowsMutationTests { } @Test("setActiveTableRows on the active tab dispatches applyFullReplace") - func dispatchesOnActiveTab() { + func dispatchesOnActiveTab() throws { let f = makeFixture() - f.tabManager.addTableTab(tableName: "users") + f.try tabManager.addTableTab(tableName: "users") let activeTabId = f.tabManager.tabs[0].id f.coordinator.setActiveTableRows(makeTableRows(rowCount: 3), for: activeTabId) @@ -88,11 +88,11 @@ struct TableRowsMutationTests { } @Test("setActiveTableRows on a background tab does not dispatch") - func skipsOnBackgroundTab() { + func skipsOnBackgroundTab() throws { let f = makeFixture() - f.tabManager.addTableTab(tableName: "users") + f.try tabManager.addTableTab(tableName: "users") let backgroundTabId = f.tabManager.tabs[0].id - f.tabManager.addTableTab(tableName: "orders") + f.try tabManager.addTableTab(tableName: "orders") f.coordinator.setActiveTableRows(makeTableRows(rowCount: 5), for: backgroundTabId) @@ -100,9 +100,9 @@ struct TableRowsMutationTests { } @Test("repeated setActiveTableRows dispatches once per call") - func dispatchesOncePerCall() { + func dispatchesOncePerCall() throws { let f = makeFixture() - f.tabManager.addTableTab(tableName: "users") + f.try tabManager.addTableTab(tableName: "users") let activeTabId = f.tabManager.tabs[0].id f.coordinator.setActiveTableRows(TableRows(), for: activeTabId) @@ -112,9 +112,9 @@ struct TableRowsMutationTests { } @Test("setActiveTableRows dispatches scrollToTop when pendingScrollToTopAfterReplace contains tabId") - func scrollToTopFiresOnPendingFlag() { + func scrollToTopFiresOnPendingFlag() throws { let f = makeFixture() - f.tabManager.addTableTab(tableName: "users") + f.try tabManager.addTableTab(tableName: "users") let activeTabId = f.tabManager.tabs[0].id f.coordinator.pendingScrollToTopAfterReplace.insert(activeTabId) @@ -125,11 +125,11 @@ struct TableRowsMutationTests { } @Test("scrollToTop pending flag for tab A does not fire when tab B is replaced") - func scrollToTopFlagIsScopedPerTab() { + func scrollToTopFlagIsScopedPerTab() throws { let f = makeFixture() - f.tabManager.addTableTab(tableName: "users") + f.try tabManager.addTableTab(tableName: "users") let firstTabId = f.tabManager.tabs[0].id - f.tabManager.addTableTab(tableName: "orders") + f.try tabManager.addTableTab(tableName: "orders") let secondTabId = f.tabManager.tabs[1].id f.coordinator.pendingScrollToTopAfterReplace.insert(firstTabId) @@ -140,9 +140,9 @@ struct TableRowsMutationTests { } @Test("setActiveTableRows without pending flag does not scroll to top") - func scrollToTopSkippedWhenFlagAbsent() { + func scrollToTopSkippedWhenFlagAbsent() throws { let f = makeFixture() - f.tabManager.addTableTab(tableName: "users") + f.try tabManager.addTableTab(tableName: "users") let activeTabId = f.tabManager.tabs[0].id f.coordinator.setActiveTableRows(makeTableRows(rowCount: 3), for: activeTabId) @@ -151,7 +151,7 @@ struct TableRowsMutationTests { } @Test("setActiveTableRows is a no-op when delegate is unwired") - func unwiredDelegateIsNoOp() { + func unwiredDelegateIsNoOp() throws { let tabManager = QueryTabManager() let coordinator = MainContentCoordinator( connection: TestFixtures.makeConnection(), @@ -161,7 +161,7 @@ struct TableRowsMutationTests { columnVisibilityManager: ColumnVisibilityManager(), toolbarState: ConnectionToolbarState() ) - tabManager.addTableTab(tableName: "users") + try tabManager.addTableTab(tableName: "users") let tabId = tabManager.tabs[0].id coordinator.setActiveTableRows(makeTableRows(rowCount: 2), for: tabId) diff --git a/TableProTests/Views/SidebarNavigationResultTests.swift b/TableProTests/Views/SidebarNavigationResultTests.swift index 68630592e..44aa725fe 100644 --- a/TableProTests/Views/SidebarNavigationResultTests.swift +++ b/TableProTests/Views/SidebarNavigationResultTests.swift @@ -169,9 +169,9 @@ struct SidebarNavigationResultTests { @Test("Resolves to skip when clicking the active table in QueryTabManager") @MainActor - func resolveSkipWithActiveTableInTabManager() { + func resolveSkipWithActiveTableInTabManager() throws { let manager = QueryTabManager() - manager.addTableTab(tableName: "users", databaseType: .mysql, databaseName: "mydb") + try manager.addTableTab(tableName: "users", databaseType: .mysql, databaseName: "mydb") let result = SidebarNavigationResult.resolve( clickedTableName: "users", currentTabTableName: manager.selectedTab?.tableContext.tableName, @@ -182,9 +182,9 @@ struct SidebarNavigationResultTests { @Test("Resolves to revertAndOpenNewWindow when clicking a different table in non-empty window") @MainActor - func resolveNewWindowWhenClickingDifferentTable() { + func resolveNewWindowWhenClickingDifferentTable() throws { let manager = QueryTabManager() - manager.addTableTab(tableName: "users", databaseType: .mysql, databaseName: "mydb") + try manager.addTableTab(tableName: "users", databaseType: .mysql, databaseName: "mydb") let result = SidebarNavigationResult.resolve( clickedTableName: "orders", currentTabTableName: manager.selectedTab?.tableContext.tableName, @@ -245,9 +245,9 @@ struct SidebarNavigationResultTests { @Test("Sync should set selection to active table name") @MainActor - func syncSetsSelectionForTableTab() { + func syncSetsSelectionForTableTab() throws { let manager = QueryTabManager() - manager.addTableTab(tableName: "users", databaseType: .mysql, databaseName: "mydb") + try manager.addTableTab(tableName: "users", databaseType: .mysql, databaseName: "mydb") let currentTableName = manager.selectedTab?.tableContext.tableName #expect(currentTableName == "users") // syncSidebarToCurrentTab will find "users" in tables and set selectedTables = [users] diff --git a/TableProTests/Views/SwitchDatabaseTests.swift b/TableProTests/Views/SwitchDatabaseTests.swift index 351d312d7..e5e8c6f87 100644 --- a/TableProTests/Views/SwitchDatabaseTests.swift +++ b/TableProTests/Views/SwitchDatabaseTests.swift @@ -55,7 +55,7 @@ struct SwitchDatabaseTests { @Test("openTableTab skips new window when sidebar is loading with existing tabs") @MainActor - func openTableTabSkipsNewWindowDuringSwitch() { + func openTableTabSkipsNewWindowDuringSwitch() throws { let connection = TestFixtures.makeConnection(database: "db_a") let tabManager = QueryTabManager() let changeManager = DataChangeManager() @@ -72,7 +72,7 @@ struct SwitchDatabaseTests { ) defer { coordinator.teardown() } - tabManager.addTableTab(tableName: "users", databaseType: .mysql, databaseName: "db_a") + try tabManager.addTableTab(tableName: "users", databaseType: .mysql, databaseName: "db_a") let tabCountBefore = tabManager.tabs.count coordinator.sidebarLoadingState = .loading @@ -115,7 +115,7 @@ struct SwitchDatabaseTests { @Test("openTableTab skips when table is already active tab in same database") @MainActor - func openTableTabSkipsForSameTableSameDatabase() { + func openTableTabSkipsForSameTableSameDatabase() throws { let connection = TestFixtures.makeConnection(database: "db_a") let tabManager = QueryTabManager() let changeManager = DataChangeManager() @@ -133,7 +133,7 @@ struct SwitchDatabaseTests { defer { coordinator.teardown() } // Add a tab for "users" in "db_a" - tabManager.addTableTab(tableName: "users", databaseType: .mysql, databaseName: "db_a") + try tabManager.addTableTab(tableName: "users", databaseType: .mysql, databaseName: "db_a") let tabCountBefore = tabManager.tabs.count // Opening "users" again in same database should be a no-op (fast path) @@ -146,9 +146,9 @@ struct SwitchDatabaseTests { @Test("switchDatabase clears all table tabs") @MainActor - func switchDatabaseClearsTableTabs() { + func switchDatabaseClearsTableTabs() throws { let tabManager = QueryTabManager() - tabManager.addTableTab(tableName: "users", databaseType: .mysql, databaseName: "db_a") + try tabManager.addTableTab(tableName: "users", databaseType: .mysql, databaseName: "db_a") simulateDatabaseSwitch(tabManager: tabManager) @@ -170,11 +170,11 @@ struct SwitchDatabaseTests { @Test("switchDatabase clears mixed table and query tabs") @MainActor - func switchDatabaseClearsMixedTabs() { + func switchDatabaseClearsMixedTabs() throws { let tabManager = QueryTabManager() - tabManager.addTableTab(tableName: "users", databaseType: .mysql, databaseName: "db_a") + try tabManager.addTableTab(tableName: "users", databaseType: .mysql, databaseName: "db_a") tabManager.addTab(initialQuery: "SELECT NOW()", databaseName: "db_a") - tabManager.addTableTab(tableName: "orders", databaseType: .mysql, databaseName: "db_a") + try tabManager.addTableTab(tableName: "orders", databaseType: .mysql, databaseName: "db_a") #expect(tabManager.tabs.count == 3) simulateDatabaseSwitch(tabManager: tabManager) From 0b1a39b9f795769f9f4b39c1b18a4c20fe52021b Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 30 Apr 2026 22:05:19 +0700 Subject: [PATCH 04/29] docs: add changelog entries for ssh, storage, dialect fixes --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index dd074f5f3..0b9a66750 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,6 +72,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Native Search Field focus regression when clearing text - PostgreSQL Create Database failed with `new collation incompatible with template database` on glibc-initialized servers (#927). Encodings, collations, and the `template1` defaults are now read from the server. `LC_CTYPE` mirrors `LC_COLLATE`, and `TEMPLATE template0` is added automatically when the chosen collation differs from `template1.datcollate`. - Redshift Create Database emitted PostgreSQL `LC_COLLATE` syntax which is invalid Redshift grammar. Now emits `COLLATE { CASE_SENSITIVE | CASE_INSENSITIVE }`. +- Expand tilde in SSH agent socket and `IdentityAgent` paths so 1Password and similar agents work when configured with `~/...` paths. +- Persist group deletions before firing the sync notification, fixing a race that could re-upload deleted groups via iCloud. +- Refuse to generate SQL when the database dialect cannot be resolved, instead of silently emitting unquoted identifiers. ## [0.36.0] - 2026-04-27 From d57fb83115d8aaee61e2e4107fba25a8d8bd0c88 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 30 Apr 2026 21:46:32 +0700 Subject: [PATCH 05/29] test: delete dead LIMIT-1 codegen test methods --- .../SQLStatementGeneratorMSSQLTests.swift | 14 -- .../SQLStatementGeneratorNoPKTests.swift | 171 ------------------ .../SQLStatementGeneratorTests.swift | 144 --------------- 3 files changed, 329 deletions(-) diff --git a/TableProTests/Core/ChangeTracking/SQLStatementGeneratorMSSQLTests.swift b/TableProTests/Core/ChangeTracking/SQLStatementGeneratorMSSQLTests.swift index 3506dafe8..e98813f85 100644 --- a/TableProTests/Core/ChangeTracking/SQLStatementGeneratorMSSQLTests.swift +++ b/TableProTests/Core/ChangeTracking/SQLStatementGeneratorMSSQLTests.swift @@ -182,18 +182,4 @@ struct SQLStatementGeneratorMSSQLTests { #expect(sql.contains("DELETE FROM [users]")) #expect(sql.contains("WHERE [id] = ?")) } - - @Test("DELETE does not add LIMIT clause for MSSQL") - func deleteNoLimitClause() throws { - let generator = try makeGenerator() - let statements = generator.generateStatements( - from: [makeDeleteChange()], - insertedRowData: [:], - deletedRowIndices: [0], - insertedRowIndices: [] - ) - - #expect(statements.count == 1) - #expect(!statements[0].sql.contains("LIMIT")) - } } diff --git a/TableProTests/Core/ChangeTracking/SQLStatementGeneratorNoPKTests.swift b/TableProTests/Core/ChangeTracking/SQLStatementGeneratorNoPKTests.swift index 93ac4a824..503e910cf 100644 --- a/TableProTests/Core/ChangeTracking/SQLStatementGeneratorNoPKTests.swift +++ b/TableProTests/Core/ChangeTracking/SQLStatementGeneratorNoPKTests.swift @@ -60,127 +60,6 @@ struct SQLStatementGeneratorNoPKTests { #expect(stmt.sql.contains("`id` = ?")) #expect(stmt.sql.contains("`name` = ?")) #expect(stmt.sql.contains("`email` = ?")) - #expect(stmt.sql.contains("LIMIT 1")) - } - - @Test("Update without PK — MySQL uses LIMIT 1") - func testUpdateNoPKMySQLLimit() throws { - let generator = try makeGenerator(databaseType: .mysql) - let changes: [RowChange] = [ - RowChange( - rowIndex: 0, - type: .update, - cellChanges: [ - CellChange(rowIndex: 0, columnIndex: 1, columnName: "name", oldValue: "John", newValue: "Johnny") - ], - originalRow: ["1", "John", "john@example.com"] - ) - ] - - let statements = generator.generateStatements( - from: changes, - insertedRowData: [:], - deletedRowIndices: [], - insertedRowIndices: [] - ) - - #expect(statements.count == 1) - let stmt = statements[0] - #expect(stmt.sql.contains("LIMIT 1")) - #expect(stmt.sql.contains("SET `name` = ?")) - #expect(stmt.sql.contains("WHERE `id` = ? AND `name` = ? AND `email` = ?")) - #expect(stmt.parameters.count == 4) // 1 SET + 3 WHERE - #expect(stmt.parameters[0] as? String == "Johnny") - #expect(stmt.parameters[1] as? String == "1") - #expect(stmt.parameters[2] as? String == "John") - #expect(stmt.parameters[3] as? String == "john@example.com") - } - - @Test("Update without PK — PostgreSQL uses $N placeholders, no LIMIT") - func testUpdateNoPKPostgreSQLNoLimit() throws { - let generator = try makeGenerator(databaseType: .postgresql) - let changes: [RowChange] = [ - RowChange( - rowIndex: 0, - type: .update, - cellChanges: [ - CellChange(rowIndex: 0, columnIndex: 1, columnName: "name", oldValue: "John", newValue: "Johnny") - ], - originalRow: ["1", "John", "john@example.com"] - ) - ] - - let statements = generator.generateStatements( - from: changes, - insertedRowData: [:], - deletedRowIndices: [], - insertedRowIndices: [] - ) - - #expect(statements.count == 1) - let stmt = statements[0] - #expect(!stmt.sql.contains("LIMIT")) - #expect(!stmt.sql.contains("TOP")) - #expect(stmt.sql.contains("\"name\" = $1")) - #expect(stmt.sql.contains("\"id\" = $2")) - #expect(stmt.sql.contains("\"name\" = $3")) - #expect(stmt.sql.contains("\"email\" = $4")) - #expect(stmt.parameters.count == 4) - } - - @Test("Update without PK — SQLite uses LIMIT 1") - func testUpdateNoPKSQLiteLimit() throws { - let generator = try makeGenerator(databaseType: .sqlite) - let changes: [RowChange] = [ - RowChange( - rowIndex: 0, - type: .update, - cellChanges: [ - CellChange(rowIndex: 0, columnIndex: 1, columnName: "name", oldValue: "John", newValue: "Johnny") - ], - originalRow: ["1", "John", "john@example.com"] - ) - ] - - let statements = generator.generateStatements( - from: changes, - insertedRowData: [:], - deletedRowIndices: [], - insertedRowIndices: [] - ) - - #expect(statements.count == 1) - let stmt = statements[0] - #expect(stmt.sql.contains("LIMIT 1")) - #expect(stmt.sql.contains("SET `name` = ?")) - } - - @Test("Update without PK — MSSQL uses UPDATE TOP (1)") - func testUpdateNoPKMSSQLTop() throws { - let generator = try makeGenerator(databaseType: .mssql) - let changes: [RowChange] = [ - RowChange( - rowIndex: 0, - type: .update, - cellChanges: [ - CellChange(rowIndex: 0, columnIndex: 1, columnName: "name", oldValue: "John", newValue: "Johnny") - ], - originalRow: ["1", "John", "john@example.com"] - ) - ] - - let statements = generator.generateStatements( - from: changes, - insertedRowData: [:], - deletedRowIndices: [], - insertedRowIndices: [] - ) - - #expect(statements.count == 1) - let stmt = statements[0] - #expect(stmt.sql.hasPrefix("UPDATE TOP (1)")) - #expect(!stmt.sql.contains("LIMIT")) - #expect(stmt.sql.contains("[name] = ?")) } @Test("Update without PK — NULL in originalRow uses IS NULL") @@ -265,56 +144,6 @@ struct SQLStatementGeneratorNoPKTests { // MARK: - DELETE without PK - @Test("Delete without PK — MSSQL uses DELETE TOP (1)") - func testDeleteNoPKMSSQLTop() throws { - let generator = try makeGenerator(databaseType: .mssql) - let changes: [RowChange] = [ - RowChange( - rowIndex: 0, - type: .delete, - cellChanges: [], - originalRow: ["1", "John", "john@example.com"] - ) - ] - - let statements = generator.generateStatements( - from: changes, - insertedRowData: [:], - deletedRowIndices: [0], - insertedRowIndices: [] - ) - - #expect(statements.count == 1) - let stmt = statements[0] - #expect(stmt.sql.hasPrefix("DELETE TOP (1) FROM")) - #expect(!stmt.sql.contains("LIMIT")) - } - - @Test("Delete without PK — SQLite uses LIMIT 1") - func testDeleteNoPKSQLiteLimit() throws { - let generator = try makeGenerator(databaseType: .sqlite) - let changes: [RowChange] = [ - RowChange( - rowIndex: 0, - type: .delete, - cellChanges: [], - originalRow: ["1", "John", "john@example.com"] - ) - ] - - let statements = generator.generateStatements( - from: changes, - insertedRowData: [:], - deletedRowIndices: [0], - insertedRowIndices: [] - ) - - #expect(statements.count == 1) - let stmt = statements[0] - #expect(stmt.sql.contains("LIMIT 1")) - #expect(stmt.sql.contains("DELETE FROM")) - } - @Test("Delete without PK — multiple rows generate individual DELETEs") func testDeleteNoPKMultipleRows() throws { let generator = try makeGenerator() diff --git a/TableProTests/Core/ChangeTracking/SQLStatementGeneratorTests.swift b/TableProTests/Core/ChangeTracking/SQLStatementGeneratorTests.swift index e90ffaa9a..a58f09dda 100644 --- a/TableProTests/Core/ChangeTracking/SQLStatementGeneratorTests.swift +++ b/TableProTests/Core/ChangeTracking/SQLStatementGeneratorTests.swift @@ -419,56 +419,6 @@ struct SQLStatementGeneratorTests { #expect(stmt.parameters.count == 1) } - @Test("MySQL/MariaDB update adds LIMIT 1") - func testUpdateMySQLLimitOne() throws { - let generator = try makeGenerator(databaseType: .mysql) - let changes: [RowChange] = [ - RowChange( - rowIndex: 0, - type: .update, - cellChanges: [ - CellChange(rowIndex: 0, columnIndex: 1, columnName: "name", oldValue: "John", newValue: "Johnny") - ], - originalRow: ["1", "John", "john@example.com"] - ) - ] - - let statements = generator.generateStatements( - from: changes, - insertedRowData: [:], - deletedRowIndices: [], - insertedRowIndices: [] - ) - - #expect(statements.count == 1) - #expect(statements[0].sql.contains("LIMIT 1")) - } - - @Test("PostgreSQL update does NOT add LIMIT 1") - func testUpdatePostgreSQLNoLimit() throws { - let generator = try makeGenerator(databaseType: .postgresql) - let changes: [RowChange] = [ - RowChange( - rowIndex: 0, - type: .update, - cellChanges: [ - CellChange(rowIndex: 0, columnIndex: 1, columnName: "name", oldValue: "John", newValue: "Johnny") - ], - originalRow: ["1", "John", "john@example.com"] - ) - ] - - let statements = generator.generateStatements( - from: changes, - insertedRowData: [:], - deletedRowIndices: [], - insertedRowIndices: [] - ) - - #expect(statements.count == 1) - #expect(!statements[0].sql.contains("LIMIT")) - } - @Test("PostgreSQL update uses $1, $2 placeholders in order") func testUpdatePostgreSQLPlaceholders() throws { let generator = try makeGenerator(databaseType: .postgresql) @@ -625,52 +575,6 @@ struct SQLStatementGeneratorTests { #expect(stmt.parameters.count == 2) } - @Test("MySQL/MariaDB individual delete adds LIMIT 1") - func testDeleteMySQLLimitOne() throws { - let generator = try makeGenerator(primaryKeyColumns: [], databaseType: .mysql) - let changes: [RowChange] = [ - RowChange( - rowIndex: 0, - type: .delete, - cellChanges: [], - originalRow: ["1", "John", "john@example.com"] - ) - ] - - let statements = generator.generateStatements( - from: changes, - insertedRowData: [:], - deletedRowIndices: [0], - insertedRowIndices: [] - ) - - #expect(statements.count == 1) - #expect(statements[0].sql.contains("LIMIT 1")) - } - - @Test("PostgreSQL delete no LIMIT 1") - func testDeletePostgreSQLNoLimit() throws { - let generator = try makeGenerator(primaryKeyColumns: [], databaseType: .postgresql) - let changes: [RowChange] = [ - RowChange( - rowIndex: 0, - type: .delete, - cellChanges: [], - originalRow: ["1", "John", "john@example.com"] - ) - ] - - let statements = generator.generateStatements( - from: changes, - insertedRowData: [:], - deletedRowIndices: [0], - insertedRowIndices: [] - ) - - #expect(statements.count == 1) - #expect(!statements[0].sql.contains("LIMIT")) - } - @Test("PostgreSQL delete uses $N placeholders") func testDeletePostgreSQLPlaceholders() throws { let generator = try makeGenerator(databaseType: .postgresql) @@ -1081,31 +985,6 @@ struct SQLStatementGeneratorTests { #expect(!stmt.sql.contains("?")) } - @Test("Redshift update does NOT add LIMIT 1") - func testUpdateRedshiftNoLimit() throws { - let generator = try makeGenerator(databaseType: .redshift) - let changes: [RowChange] = [ - RowChange( - rowIndex: 0, - type: .update, - cellChanges: [ - CellChange(rowIndex: 0, columnIndex: 1, columnName: "name", oldValue: "John", newValue: "Johnny") - ], - originalRow: ["1", "John", "john@example.com"] - ) - ] - - let statements = generator.generateStatements( - from: changes, - insertedRowData: [:], - deletedRowIndices: [], - insertedRowIndices: [] - ) - - #expect(statements.count == 1) - #expect(!statements[0].sql.contains("LIMIT")) - } - @Test("Redshift delete uses $N placeholders") func testDeleteRedshiftPlaceholders() throws { let generator = try makeGenerator(databaseType: .redshift) @@ -1128,29 +1007,6 @@ struct SQLStatementGeneratorTests { #expect(!stmt.sql.contains("?")) } - @Test("Redshift delete no LIMIT 1") - func testDeleteRedshiftNoLimit() throws { - let generator = try makeGenerator(primaryKeyColumns: [], databaseType: .redshift) - let changes: [RowChange] = [ - RowChange( - rowIndex: 0, - type: .delete, - cellChanges: [], - originalRow: ["1", "John", "john@example.com"] - ) - ] - - let statements = generator.generateStatements( - from: changes, - insertedRowData: [:], - deletedRowIndices: [0], - insertedRowIndices: [] - ) - - #expect(statements.count == 1) - #expect(!statements[0].sql.contains("LIMIT")) - } - @Test("Redshift uses $1, $2, $3 sequentially for insert") func testRedshiftSequentialPlaceholders() throws { let generator = try makeGenerator(databaseType: .redshift) From 1a5a9866120ee80d789154603e93bbe3a73dfd71 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 30 Apr 2026 21:48:07 +0700 Subject: [PATCH 06/29] test: update stale icon and SF Symbol expectations --- TableProTests/Models/DatabaseTypeCassandraTests.swift | 4 ++-- TableProTests/Models/SafeModeLevelTests.swift | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/TableProTests/Models/DatabaseTypeCassandraTests.swift b/TableProTests/Models/DatabaseTypeCassandraTests.swift index 3bba70abf..593e2ac26 100644 --- a/TableProTests/Models/DatabaseTypeCassandraTests.swift +++ b/TableProTests/Models/DatabaseTypeCassandraTests.swift @@ -68,9 +68,9 @@ struct DatabaseTypeCassandraTests { #expect(DatabaseType.cassandra.iconName == "cassandra-icon") } - @Test("ScyllaDB icon name is scylladb-icon") + @Test("ScyllaDB icon name is cassandra-icon") func scylladbIconName() { - #expect(DatabaseType.scylladb.iconName == "scylladb-icon") + #expect(DatabaseType.scylladb.iconName == "cassandra-icon") } @Test("Cassandra is a downloadable plugin") diff --git a/TableProTests/Models/SafeModeLevelTests.swift b/TableProTests/Models/SafeModeLevelTests.swift index 5aec16d0e..357eec5bc 100644 --- a/TableProTests/Models/SafeModeLevelTests.swift +++ b/TableProTests/Models/SafeModeLevelTests.swift @@ -122,7 +122,7 @@ struct SafeModeLevelTests { @Test("each case has the correct SF Symbol icon name") func iconNames() { - #expect(SafeModeLevel.silent.iconName == "lock.open") + #expect(SafeModeLevel.silent.iconName == "lock.open.fill") #expect(SafeModeLevel.alert.iconName == "exclamationmark.triangle") #expect(SafeModeLevel.alertFull.iconName == "exclamationmark.triangle.fill") #expect(SafeModeLevel.safeMode.iconName == "lock.shield") From 77cc6f92f371c37b89117c41e0f3220d8defd3cc Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 30 Apr 2026 21:59:37 +0700 Subject: [PATCH 07/29] test: add MSSQL plugin stub for unit test bundle --- .../TableQueryBuilderMSSQLTests.swift | 16 +- TableProTests/Helpers/FakeMSSQLPlugin.swift | 156 ++++++++++++++++++ 2 files changed, 169 insertions(+), 3 deletions(-) create mode 100644 TableProTests/Helpers/FakeMSSQLPlugin.swift diff --git a/TableProTests/Core/Services/TableQueryBuilderMSSQLTests.swift b/TableProTests/Core/Services/TableQueryBuilderMSSQLTests.swift index 03181e88f..9d369d289 100644 --- a/TableProTests/Core/Services/TableQueryBuilderMSSQLTests.swift +++ b/TableProTests/Core/Services/TableQueryBuilderMSSQLTests.swift @@ -9,9 +9,21 @@ import Foundation @testable import TablePro import Testing +@MainActor @Suite("Table Query Builder MSSQL") struct TableQueryBuilderMSSQLTests { - private let builder = TableQueryBuilder(databaseType: .mssql) + private let builder: TableQueryBuilder + + init() { + FakeMSSQLPluginRegistration.registerIfNeeded() + let dialect = PluginManager.shared.sqlDialect(for: .mssql) + self.builder = TableQueryBuilder( + databaseType: .mssql, + pluginDriver: PluginManager.shared.queryBuildingDriver(for: .mssql), + dialect: dialect, + dialectQuote: quoteIdentifierFromDialect(dialect) + ) + } // MARK: - Base Query Tests @@ -44,8 +56,6 @@ struct TableQueryBuilderMSSQLTests { func baseQueryNoMySQLLimitSyntax() { let query = builder.buildBaseQuery(tableName: "users") let normalized = query.uppercased() - // LIMIT keyword should not appear as standalone MySQL pagination - // (FETCH NEXT is the MSSQL style) #expect(!normalized.contains(" LIMIT ")) } diff --git a/TableProTests/Helpers/FakeMSSQLPlugin.swift b/TableProTests/Helpers/FakeMSSQLPlugin.swift new file mode 100644 index 000000000..b99451763 --- /dev/null +++ b/TableProTests/Helpers/FakeMSSQLPlugin.swift @@ -0,0 +1,156 @@ +// +// FakeMSSQLPlugin.swift +// TableProTests +// +// Minimal MSSQL driver stub registered with PluginManager so tests that +// resolve the SQL Server plugin (queryBuildingDriver, sqlDialect lookups) +// succeed without bundling the real MSSQLDriverPlugin. +// + +import Foundation +import TableProPluginKit +@testable import TablePro + +final class FakeMSSQLPlugin: NSObject, TableProPlugin, DriverPlugin { + static let pluginName = "Fake MSSQL Driver" + static let pluginVersion = "1.0.0" + static let pluginDescription = "Test stub for MSSQL plugin lookups" + static let capabilities: [PluginCapability] = [.databaseDriver] + + static let databaseTypeId = "SQL Server" + static let databaseDisplayName = "SQL Server" + static let iconName = "mssql-icon" + static let defaultPort = 1_433 + static let isDownloadable = true + static let parameterStyle: ParameterStyle = .questionMark + static let supportsSchemaSwitching = true + static let defaultSchemaName = "dbo" + + static let sqlDialect: SQLDialectDescriptor? = SQLDialectDescriptor( + identifierQuote: "[", + keywords: [], + functions: [], + dataTypes: [], + regexSyntax: .unsupported, + booleanLiteralStyle: .numeric, + likeEscapeStyle: .explicit, + paginationStyle: .offsetFetch, + autoLimitStyle: .top + ) + + func createDriver(config: DriverConnectionConfig) -> any PluginDatabaseDriver { + FakeMSSQLPluginDriver() + } + + required override init() { + super.init() + } +} + +final class FakeMSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { + var supportsSchemas: Bool { true } + var currentSchema: String? { "dbo" } + var parameterStyle: ParameterStyle { .questionMark } + + func connect() async throws {} + func disconnect() {} + + func execute(query: String) async throws -> PluginQueryResult { + PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0) + } + + func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } + func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] } + func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } + func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } + func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } + func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } + func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { + PluginTableMetadata(tableName: table) + } + func fetchDatabases() async throws -> [String] { [] } + func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { + PluginDatabaseMetadata(name: database) + } + + func quoteIdentifier(_ name: String) -> String { + let escaped = name.replacingOccurrences(of: "]", with: "]]") + return "[\(escaped)]" + } + + func buildBrowseQuery( + table: String, + sortColumns: [(columnIndex: Int, ascending: Bool)], + columns: [String], + limit: Int, + offset: Int + ) -> String? { + let quotedTable = quoteIdentifier(table) + let orderBy = orderByClause(sortColumns: sortColumns, columns: columns) ?? "ORDER BY (SELECT NULL)" + return "SELECT * FROM \(quotedTable) \(orderBy) OFFSET \(offset) ROWS FETCH NEXT \(limit) ROWS ONLY" + } + + func buildFilteredQuery( + table: String, + filters: [(column: String, op: String, value: String)], + logicMode: String, + sortColumns: [(columnIndex: Int, ascending: Bool)], + columns: [String], + limit: Int, + offset: Int + ) -> String? { + let quotedTable = quoteIdentifier(table) + var query = "SELECT * FROM \(quotedTable)" + let whereClause = whereClause(filters: filters, logicMode: logicMode) + if !whereClause.isEmpty { + query += " WHERE \(whereClause)" + } + let orderBy = orderByClause(sortColumns: sortColumns, columns: columns) ?? "ORDER BY (SELECT NULL)" + query += " \(orderBy) OFFSET \(offset) ROWS FETCH NEXT \(limit) ROWS ONLY" + return query + } + + private func orderByClause( + sortColumns: [(columnIndex: Int, ascending: Bool)], + columns: [String] + ) -> String? { + let parts = sortColumns.compactMap { sortCol -> String? in + guard sortCol.columnIndex >= 0, sortCol.columnIndex < columns.count else { return nil } + let direction = sortCol.ascending ? "ASC" : "DESC" + return "\(quoteIdentifier(columns[sortCol.columnIndex])) \(direction)" + } + guard !parts.isEmpty else { return nil } + return "ORDER BY " + parts.joined(separator: ", ") + } + + private func whereClause( + filters: [(column: String, op: String, value: String)], + logicMode: String + ) -> String { + let connector = logicMode.lowercased() == "or" ? " OR " : " AND " + let parts = filters.map { filter in + "\(quoteIdentifier(filter.column)) \(filter.op) '\(filter.value)'" + } + return parts.joined(separator: connector) + } +} + +enum FakeMSSQLPluginRegistration { + private static var didRegister = false + private static let lock = NSLock() + + @MainActor + static func registerIfNeeded() { + lock.lock() + defer { lock.unlock() } + guard !didRegister else { return } + let manager = PluginManager.shared + if manager.driverPlugins[FakeMSSQLPlugin.databaseTypeId] != nil { + didRegister = true + return + } + let instance = FakeMSSQLPlugin() + manager.driverPlugins[FakeMSSQLPlugin.databaseTypeId] = instance + didRegister = true + } +} From be69ff2b04d19909ae2f1888996be08e746e21b0 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 30 Apr 2026 22:00:12 +0700 Subject: [PATCH 08/29] test: disable parallel execution in TablePro scheme --- TablePro.xcodeproj/xcshareddata/xcschemes/TablePro.xcscheme | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TablePro.xcodeproj/xcshareddata/xcschemes/TablePro.xcscheme b/TablePro.xcodeproj/xcshareddata/xcschemes/TablePro.xcscheme index d2a10cf3d..8ff51541e 100644 --- a/TablePro.xcodeproj/xcshareddata/xcschemes/TablePro.xcscheme +++ b/TablePro.xcodeproj/xcshareddata/xcschemes/TablePro.xcscheme @@ -33,7 +33,7 @@ + parallelizable = "NO"> Date: Thu, 30 Apr 2026 22:57:14 +0700 Subject: [PATCH 09/29] revert: re-enable parallel test execution --- TablePro.xcodeproj/xcshareddata/xcschemes/TablePro.xcscheme | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TablePro.xcodeproj/xcshareddata/xcschemes/TablePro.xcscheme b/TablePro.xcodeproj/xcshareddata/xcschemes/TablePro.xcscheme index 8ff51541e..d2a10cf3d 100644 --- a/TablePro.xcodeproj/xcshareddata/xcschemes/TablePro.xcscheme +++ b/TablePro.xcodeproj/xcshareddata/xcschemes/TablePro.xcscheme @@ -33,7 +33,7 @@ + parallelizable = "YES"> Date: Thu, 30 Apr 2026 22:59:54 +0700 Subject: [PATCH 10/29] refactor(storage): inject UserDefaults and dependencies into GroupStorage --- TablePro/Core/Storage/GroupStorage.swift | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/TablePro/Core/Storage/GroupStorage.swift b/TablePro/Core/Storage/GroupStorage.swift index 9ca99611f..0870a85f9 100644 --- a/TablePro/Core/Storage/GroupStorage.swift +++ b/TablePro/Core/Storage/GroupStorage.swift @@ -13,12 +13,22 @@ final class GroupStorage { private static let logger = Logger(subsystem: "com.TablePro", category: "GroupStorage") private let groupsKey = "com.TablePro.groups" - private let defaults = UserDefaults.standard + private let defaults: UserDefaults + private let syncTracker: SyncChangeTracker + private let connectionStorageProvider: () -> ConnectionStorage private let encoder = JSONEncoder() private let decoder = JSONDecoder() private var cachedGroups: [ConnectionGroup]? - private init() {} + init( + userDefaults: UserDefaults = .standard, + syncTracker: SyncChangeTracker = .shared, + connectionStorage: @escaping @autoclosure () -> ConnectionStorage = .shared + ) { + self.defaults = userDefaults + self.syncTracker = syncTracker + self.connectionStorageProvider = connectionStorage + } // MARK: - Group CRUD @@ -48,7 +58,7 @@ final class GroupStorage { let data = try encoder.encode(groups) defaults.set(data, forKey: groupsKey) cachedGroups = nil - SyncChangeTracker.shared.markDirty(.group, ids: groups.map { $0.id.uuidString }) + syncTracker.markDirty(.group, ids: groups.map { $0.id.uuidString }) } catch { Self.logger.error("Failed to save groups: \(error)") } @@ -89,10 +99,10 @@ final class GroupStorage { saveGroups(groups) for deletedId in allIdsToDelete { - SyncChangeTracker.shared.markDeleted(.group, id: deletedId.uuidString) + syncTracker.markDeleted(.group, id: deletedId.uuidString) } - let storage = ConnectionStorage.shared + let storage = connectionStorageProvider() var connections = storage.loadConnections() var changed = false for i in connections.indices { From c1ac5c91155bb5853841b803a2576b173e40a7bd Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 30 Apr 2026 23:00:08 +0700 Subject: [PATCH 11/29] refactor(storage): inject UserDefaults into AppSettingsStorage --- TablePro/Core/Storage/AppSettingsStorage.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/TablePro/Core/Storage/AppSettingsStorage.swift b/TablePro/Core/Storage/AppSettingsStorage.swift index 973832e54..c48195850 100644 --- a/TablePro/Core/Storage/AppSettingsStorage.swift +++ b/TablePro/Core/Storage/AppSettingsStorage.swift @@ -14,7 +14,7 @@ final class AppSettingsStorage { static let shared = AppSettingsStorage() private static let logger = Logger(subsystem: "com.TablePro", category: "AppSettingsStorage") - private let defaults = UserDefaults.standard + private let defaults: UserDefaults private let decoder = JSONDecoder() private let encoder = JSONEncoder() @@ -37,7 +37,9 @@ final class AppSettingsStorage { static let hasCompletedOnboarding = "com.TablePro.settings.hasCompletedOnboarding" } - private init() {} + init(userDefaults: UserDefaults = .standard) { + self.defaults = userDefaults + } // MARK: - General Settings From e49e3b580da7882af439c96fe4901f8686a45558 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 30 Apr 2026 23:01:30 +0700 Subject: [PATCH 12/29] refactor(storage): inject file URL and dependencies into ConnectionStorage --- TablePro/Core/Storage/ConnectionStorage.swift | 48 ++++++++++++------- 1 file changed, 32 insertions(+), 16 deletions(-) diff --git a/TablePro/Core/Storage/ConnectionStorage.swift b/TablePro/Core/Storage/ConnectionStorage.swift index 54a80ca7d..0224b4e45 100644 --- a/TablePro/Core/Storage/ConnectionStorage.swift +++ b/TablePro/Core/Storage/ConnectionStorage.swift @@ -17,7 +17,9 @@ final class ConnectionStorage { private let connectionsKey = "com.TablePro.connections" private let migratedToFileKey = "com.TablePro.connectionsMigratedToFile" - private let defaults = UserDefaults.standard + private let defaults: UserDefaults + private let syncTracker: SyncChangeTracker + private let appSettingsProvider: () -> AppSettingsStorage private let encoder = JSONEncoder() private let decoder = JSONDecoder() @@ -26,16 +28,28 @@ final class ConnectionStorage { private let fileURL: URL - private init() { + init( + fileURL: URL = ConnectionStorage.defaultFileURL(), + userDefaults: UserDefaults = .standard, + syncTracker: SyncChangeTracker = .shared, + appSettings: @escaping @autoclosure () -> AppSettingsStorage = .shared + ) { + self.fileURL = fileURL + self.defaults = userDefaults + self.syncTracker = syncTracker + self.appSettingsProvider = appSettings + + migrateFromUserDefaultsIfNeeded() + } + + nonisolated static func defaultFileURL() -> URL { let appSupport = FileManager.default.urls( for: .applicationSupportDirectory, in: .userDomainMask ).first ?? FileManager.default.temporaryDirectory let dir = appSupport.appendingPathComponent("TablePro", isDirectory: true) try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) - fileURL = dir.appendingPathComponent("connections.json") - - migrateFromUserDefaultsIfNeeded() + return dir.appendingPathComponent("connections.json") } /// One-time migration from UserDefaults to atomic file storage. @@ -113,7 +127,7 @@ final class ConnectionStorage { connections.append(connection) saveConnections(connections) if !connection.localOnly { - SyncChangeTracker.shared.markDirty(.connection, id: connection.id.uuidString) + syncTracker.markDirty(.connection, id: connection.id.uuidString) } if let password = password, !password.isEmpty { @@ -128,7 +142,7 @@ final class ConnectionStorage { connections[index] = connection saveConnections(connections) if !connection.localOnly { - SyncChangeTracker.shared.markDirty(.connection, id: connection.id.uuidString) + syncTracker.markDirty(.connection, id: connection.id.uuidString) } if let password = password { @@ -144,7 +158,7 @@ final class ConnectionStorage { /// Delete a connection func deleteConnection(_ connection: DatabaseConnection) { if !connection.localOnly { - SyncChangeTracker.shared.markDeleted(.connection, id: connection.id.uuidString) + syncTracker.markDeleted(.connection, id: connection.id.uuidString) } var connections = loadConnections() connections.removeAll { $0.id == connection.id } @@ -157,14 +171,15 @@ final class ConnectionStorage { let secureFieldIds = Self.secureFieldIds(for: connection.type) deleteAllPluginSecureFields(for: connection.id, fieldIds: secureFieldIds) - AppSettingsStorage.shared.saveLastDatabase(nil, for: connection.id) - AppSettingsStorage.shared.saveLastSchema(nil, for: connection.id) + let appSettings = appSettingsProvider() + appSettings.saveLastDatabase(nil, for: connection.id) + appSettings.saveLastSchema(nil, for: connection.id) } /// Batch-delete multiple connections and clean up their Keychain entries func deleteConnections(_ connectionsToDelete: [DatabaseConnection]) { for conn in connectionsToDelete where !conn.localOnly { - SyncChangeTracker.shared.markDeleted(.connection, id: conn.id.uuidString) + syncTracker.markDeleted(.connection, id: conn.id.uuidString) } let idsToDelete = Set(connectionsToDelete.map(\.id)) var all = loadConnections() @@ -177,8 +192,9 @@ final class ConnectionStorage { deleteTOTPSecret(for: conn.id) let fields = Self.secureFieldIds(for: conn.type) deleteAllPluginSecureFields(for: conn.id, fieldIds: fields) - AppSettingsStorage.shared.saveLastDatabase(nil, for: conn.id) - AppSettingsStorage.shared.saveLastSchema(nil, for: conn.id) + let appSettings = appSettingsProvider() + appSettings.saveLastDatabase(nil, for: conn.id) + appSettings.saveLastSchema(nil, for: conn.id) } } @@ -217,7 +233,7 @@ final class ConnectionStorage { connections.append(duplicate) saveConnections(connections) if !duplicate.localOnly { - SyncChangeTracker.shared.markDirty(.connection, id: duplicate.id.uuidString) + syncTracker.markDirty(.connection, id: duplicate.id.uuidString) } // Copy all passwords from source to duplicate (skip DB password in prompt mode) @@ -357,8 +373,8 @@ final class ConnectionStorage { func migratePluginSecureFieldsIfNeeded() { let migrationKey = "com.TablePro.pluginSecureFieldsMigrated" - guard !UserDefaults.standard.bool(forKey: migrationKey) else { return } - defer { UserDefaults.standard.set(true, forKey: migrationKey) } + guard !defaults.bool(forKey: migrationKey) else { return } + defer { defaults.set(true, forKey: migrationKey) } var connections = loadConnections() var changed = false From 9894916a2358caf9dc7f0de7e55cb3c3a3a80b2b Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 30 Apr 2026 23:01:49 +0700 Subject: [PATCH 13/29] refactor(sync): inject UserDefaults into SyncMetadataStorage --- TablePro/Core/Sync/SyncMetadataStorage.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/TablePro/Core/Sync/SyncMetadataStorage.swift b/TablePro/Core/Sync/SyncMetadataStorage.swift index 40e292d70..01b2044d6 100644 --- a/TablePro/Core/Sync/SyncMetadataStorage.swift +++ b/TablePro/Core/Sync/SyncMetadataStorage.swift @@ -14,7 +14,7 @@ final class SyncMetadataStorage { static let shared = SyncMetadataStorage() private static let logger = Logger(subsystem: "com.TablePro", category: "SyncMetadataStorage") - private let defaults = UserDefaults.standard + private let defaults: UserDefaults private enum Keys { static let syncToken = "com.TablePro.sync.serverChangeToken" @@ -24,7 +24,9 @@ final class SyncMetadataStorage { static let lastAccountId = "com.TablePro.sync.lastAccountId" } - private init() {} + init(userDefaults: UserDefaults = .standard) { + self.defaults = userDefaults + } // MARK: - Server Change Token From 849008d145befbe3540e449493ca3a3e36ea2e80 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 30 Apr 2026 23:02:56 +0700 Subject: [PATCH 14/29] refactor(storage): inject database URL into QueryHistoryStorage --- .../Core/Storage/QueryHistoryStorage.swift | 64 ++++++++----------- 1 file changed, 28 insertions(+), 36 deletions(-) diff --git a/TablePro/Core/Storage/QueryHistoryStorage.swift b/TablePro/Core/Storage/QueryHistoryStorage.swift index bb0bb965e..21f8e6bf5 100644 --- a/TablePro/Core/Storage/QueryHistoryStorage.swift +++ b/TablePro/Core/Storage/QueryHistoryStorage.swift @@ -34,33 +34,41 @@ actor QueryHistoryStorage { private var cachedMaxHistoryDays: Int = 90 private var insertsSinceCleanup: Int = 0 - private static var isRunningTests: Bool { - NSClassFromString("XCTestCase") != nil - } - - private init() { + private let databaseURL: URL + private let removeDatabaseOnDeinit: Bool + + init( + databaseURL: URL = QueryHistoryStorage.defaultDatabaseURL(), + removeDatabaseOnDeinit: Bool = false + ) { + self.databaseURL = databaseURL + self.removeDatabaseOnDeinit = removeDatabaseOnDeinit setupDatabase() } - #if DEBUG - init(isolatedForTesting: Bool) { - testDatabaseSuffix = isolatedForTesting ? "_\(UUID().uuidString)" : nil - setupDatabase() + static func defaultDatabaseURL() -> URL { + let fileManager = FileManager.default + let appSupport = fileManager.urls( + for: .applicationSupportDirectory, in: .userDomainMask + ).first ?? fileManager.temporaryDirectory + let dir = appSupport.appendingPathComponent("TablePro") + try? fileManager.createDirectory(at: dir, withIntermediateDirectories: true) + let isRunningTests = NSClassFromString("XCTestCase") != nil + let fileName = isRunningTests + ? "query_history_test_\(ProcessInfo.processInfo.processIdentifier).db" + : "query_history.db" + return dir.appendingPathComponent(fileName) } - #endif - - private var testDatabaseSuffix: String? - - private var dbPath: String? deinit { if let db = db { sqlite3_close(db) } - if Self.isRunningTests, let dbPath = dbPath { - try? FileManager.default.removeItem(atPath: dbPath) + if removeDatabaseOnDeinit { + let path = databaseURL.path(percentEncoded: false) + try? FileManager.default.removeItem(atPath: path) for suffix in ["-wal", "-shm"] { - try? FileManager.default.removeItem(atPath: dbPath + suffix) + try? FileManager.default.removeItem(atPath: path + suffix) } } } @@ -68,26 +76,10 @@ actor QueryHistoryStorage { // MARK: - Database Setup private func setupDatabase() { - let fileManager = FileManager.default - guard - let appSupport = fileManager.urls( - for: .applicationSupportDirectory, in: .userDomainMask - ).first - else { - Self.logger.error("Unable to access application support directory") - return - } - let TableProDir = appSupport.appendingPathComponent("TablePro") - - try? fileManager.createDirectory(at: TableProDir, withIntermediateDirectories: true) - - let suffix = testDatabaseSuffix ?? "" - let dbFileName = Self.isRunningTests - ? "query_history_test_\(ProcessInfo.processInfo.processIdentifier)\(suffix).db" - : "query_history.db" - let dbPath = TableProDir.appendingPathComponent(dbFileName).path(percentEncoded: false) + let dir = databaseURL.deletingLastPathComponent() + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) - self.dbPath = dbPath + let dbPath = databaseURL.path(percentEncoded: false) if sqlite3_open(dbPath, &db) != SQLITE_OK { Self.logger.error("Error opening database") From b3b3c6363129f8fc5ec5b559180a79164c7457fb Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 30 Apr 2026 23:04:14 +0700 Subject: [PATCH 15/29] refactor(database): inject storage and plugin manager into DatabaseManager --- .../Core/Database/DatabaseManager+Health.swift | 2 +- .../Core/Database/DatabaseManager+SSH.swift | 6 +++--- .../Database/DatabaseManager+Sessions.swift | 18 +++++++++--------- TablePro/Core/Database/DatabaseManager.swift | 18 +++++++++++++++--- 4 files changed, 28 insertions(+), 16 deletions(-) diff --git a/TablePro/Core/Database/DatabaseManager+Health.swift b/TablePro/Core/Database/DatabaseManager+Health.swift index 0ac1a9ce8..1cb7b128c 100644 --- a/TablePro/Core/Database/DatabaseManager+Health.swift +++ b/TablePro/Core/Database/DatabaseManager+Health.swift @@ -220,7 +220,7 @@ extension DatabaseManager { // Resolve password for prompt-for-password connections var passwordOverride = activeSessions[sessionId]?.cachedPassword if session.connection.promptForPassword && passwordOverride == nil { - let isApiOnly = PluginManager.shared.connectionMode(for: session.connection.type) == .apiOnly + let isApiOnly = pluginManager.connectionMode(for: session.connection.type) == .apiOnly guard let prompted = await PasswordPromptHelper.prompt( connectionName: session.connection.name, isAPIToken: isApiOnly, diff --git a/TablePro/Core/Database/DatabaseManager+SSH.swift b/TablePro/Core/Database/DatabaseManager+SSH.swift index 2514fd002..8632fc8e1 100644 --- a/TablePro/Core/Database/DatabaseManager+SSH.swift +++ b/TablePro/Core/Database/DatabaseManager+SSH.swift @@ -39,9 +39,9 @@ extension DatabaseManager { keyPassphrase = SSHProfileStorage.shared.loadKeyPassphrase(for: profileId) totpSecret = SSHProfileStorage.shared.loadTOTPSecret(for: profileId) case .inline: - storedSshPassword = ConnectionStorage.shared.loadSSHPassword(for: connection.id) - keyPassphrase = ConnectionStorage.shared.loadKeyPassphrase(for: connection.id) - totpSecret = ConnectionStorage.shared.loadTOTPSecret(for: connection.id) + storedSshPassword = connectionStorage.loadSSHPassword(for: connection.id) + keyPassphrase = connectionStorage.loadKeyPassphrase(for: connection.id) + totpSecret = connectionStorage.loadTOTPSecret(for: connection.id) } let sshPassword = sshPasswordOverride ?? storedSshPassword diff --git a/TablePro/Core/Database/DatabaseManager+Sessions.swift b/TablePro/Core/Database/DatabaseManager+Sessions.swift index c86f7d33f..59f99a4ec 100644 --- a/TablePro/Core/Database/DatabaseManager+Sessions.swift +++ b/TablePro/Core/Database/DatabaseManager+Sessions.swift @@ -69,7 +69,7 @@ extension DatabaseManager { if let cached = activeSessions[connection.id]?.cachedPassword { passwordOverride = cached } else { - let isApiOnly = PluginManager.shared.connectionMode(for: connection.type) == .apiOnly + let isApiOnly = pluginManager.connectionMode(for: connection.type) == .apiOnly guard let prompted = await PasswordPromptHelper.prompt( connectionName: connection.name, isAPIToken: isApiOnly, @@ -150,7 +150,7 @@ extension DatabaseManager { } // Save as last connection for "Reopen Last Session" feature - AppSettingsStorage.shared.saveLastConnectionId(connection.id) + appSettingsStorage.saveLastConnectionId(connection.id) // Post notification for reliable delivery NotificationCenter.default.post(name: .databaseDidConnect, object: nil) @@ -206,7 +206,7 @@ extension DatabaseManager { case .selectDatabaseFromLastSession: if resolvedConnection.database.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, let adapter = driver as? PluginDriverAdapter, - let savedDb = AppSettingsStorage.shared.loadLastDatabase(for: connection.id) { + let savedDb = appSettingsStorage.loadLastDatabase(for: connection.id) { do { try await adapter.switchDatabase(to: savedDb) activeSessions[connection.id]?.currentDatabase = savedDb @@ -237,7 +237,7 @@ extension DatabaseManager { } case .selectSchemaFromLastSession: if let schemaDriver = driver as? SchemaSwitchable, - let savedSchema = AppSettingsStorage.shared.loadLastSchema(for: connection.id), + let savedSchema = appSettingsStorage.loadLastSchema(for: connection.id), savedSchema != schemaDriver.currentSchema { do { try await schemaDriver.switchSchema(to: savedSchema) @@ -267,7 +267,7 @@ extension DatabaseManager { session.currentDatabase = database session.currentSchema = nil } - AppSettingsStorage.shared.saveLastSchema(nil, for: connectionId) + appSettingsStorage.saveLastSchema(nil, for: connectionId) await reconnectSession(connectionId) } else if pm?.capabilities.supportsSchemaSwitching == true, let schemaDriver = driver as? SchemaSwitchable { @@ -275,7 +275,7 @@ extension DatabaseManager { updateSession(connectionId) { session in session.currentSchema = database } - AppSettingsStorage.shared.saveLastSchema(database, for: connectionId) + appSettingsStorage.saveLastSchema(database, for: connectionId) return } else if let adapter = driver as? PluginDriverAdapter { try await adapter.switchDatabase(to: database) @@ -288,7 +288,7 @@ extension DatabaseManager { } } - AppSettingsStorage.shared.saveLastDatabase(database, for: connectionId) + appSettingsStorage.saveLastDatabase(database, for: connectionId) } func switchSchema(to schema: String, for connectionId: UUID) async throws { @@ -301,7 +301,7 @@ extension DatabaseManager { updateSession(connectionId) { session in session.currentSchema = schema } - AppSettingsStorage.shared.saveLastSchema(schema, for: connectionId) + appSettingsStorage.saveLastSchema(schema, for: connectionId) } /// Switch to an existing session @@ -367,7 +367,7 @@ extension DatabaseManager { } else { // No more sessions - clear current session and last connection ID currentSessionId = nil - AppSettingsStorage.shared.saveLastConnectionId(nil) + appSettingsStorage.saveLastConnectionId(nil) } } lifecycleLogger.info( diff --git a/TablePro/Core/Database/DatabaseManager.swift b/TablePro/Core/Database/DatabaseManager.swift index ad2af8893..3f1fa4bac 100644 --- a/TablePro/Core/Database/DatabaseManager.swift +++ b/TablePro/Core/Database/DatabaseManager.swift @@ -17,6 +17,10 @@ final class DatabaseManager { static let shared = DatabaseManager() internal static let logger = Logger(subsystem: "com.TablePro", category: "DatabaseManager") + @ObservationIgnored private let connectionStorage: ConnectionStorage + @ObservationIgnored private let appSettingsStorage: AppSettingsStorage + @ObservationIgnored private let pluginManager: PluginManager + /// All active connection sessions internal(set) var activeSessions: [UUID: ConnectionSession] = [:] { didSet { @@ -82,12 +86,20 @@ final class DatabaseManager { currentSession?.status ?? .disconnected } - internal init() {} + internal init( + connectionStorage: ConnectionStorage = .shared, + appSettingsStorage: AppSettingsStorage = .shared, + pluginManager: PluginManager = .shared + ) { + self.connectionStorage = connectionStorage + self.appSettingsStorage = appSettingsStorage + self.pluginManager = pluginManager + } private func persistOpenConnectionIds() { - let connections = ConnectionStorage.shared.loadConnections() + let connections = connectionStorage.loadConnections() let activeKeys = Set(activeSessions.keys) let ids = connections.filter { activeKeys.contains($0.id) }.map(\.id) - AppSettingsStorage.shared.saveLastOpenConnectionIds(ids) + appSettingsStorage.saveLastOpenConnectionIds(ids) } } From 577bc3ee404202598196e6b2f8bfb4c3c539981a Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 30 Apr 2026 23:05:36 +0700 Subject: [PATCH 16/29] refactor(plugins): inject plugin search URLs and UserDefaults into PluginManager --- TablePro/Core/Plugins/PluginManager.swift | 40 ++++++++++++++--------- 1 file changed, 24 insertions(+), 16 deletions(-) diff --git a/TablePro/Core/Plugins/PluginManager.swift b/TablePro/Core/Plugins/PluginManager.swift index 32cfa1a3b..cbf01ad27 100644 --- a/TablePro/Core/Plugins/PluginManager.swift +++ b/TablePro/Core/Plugins/PluginManager.swift @@ -16,6 +16,10 @@ final class PluginManager { private static let disabledPluginsKey = "com.TablePro.disabledPlugins" private static let legacyDisabledPluginsKey = "disabledPlugins" + @ObservationIgnored private let defaults: UserDefaults + @ObservationIgnored private let builtInPluginsURL: URL? + @ObservationIgnored let userPluginsDir: URL + internal(set) var plugins: [PluginEntry] = [] internal(set) var isInstalling = false @@ -57,10 +61,8 @@ final class PluginManager { private static let needsRestartKey = "com.TablePro.needsRestart" - var needsRestartStorage: Bool = UserDefaults.standard.bool( - forKey: needsRestartKey - ) { - didSet { UserDefaults.standard.set(needsRestartStorage, forKey: Self.needsRestartKey) } + var needsRestartStorage: Bool { + didSet { defaults.set(needsRestartStorage, forKey: Self.needsRestartKey) } } var needsRestart: Bool { needsRestartStorage } @@ -73,16 +75,9 @@ final class PluginManager { internal(set) var pluginInstances: [String: any TableProPlugin] = [:] - private var builtInPluginsDir: URL? { Bundle.main.builtInPlugInsURL } - - var userPluginsDir: URL { - FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] - .appendingPathComponent("TablePro/Plugins", isDirectory: true) - } - var disabledPluginIds: Set { - get { Set(UserDefaults.standard.stringArray(forKey: Self.disabledPluginsKey) ?? []) } - set { UserDefaults.standard.set(Array(newValue), forKey: Self.disabledPluginsKey) } + get { Set(defaults.stringArray(forKey: Self.disabledPluginsKey) ?? []) } + set { defaults.set(Array(newValue), forKey: Self.disabledPluginsKey) } } static let logger = Logger(subsystem: "com.TablePro", category: "PluginManager") @@ -91,7 +86,21 @@ final class PluginManager { var queryBuildingDriverCache: [String: (any PluginDatabaseDriver)?] = [:] - private init() {} + init( + userDefaults: UserDefaults = .standard, + builtInPluginsURL: URL? = Bundle.main.builtInPlugInsURL, + userPluginsDir: URL = PluginManager.defaultUserPluginsDir() + ) { + self.defaults = userDefaults + self.builtInPluginsURL = builtInPluginsURL + self.userPluginsDir = userPluginsDir + self.needsRestartStorage = userDefaults.bool(forKey: Self.needsRestartKey) + } + + nonisolated static func defaultUserPluginsDir() -> URL { + FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + .appendingPathComponent("TablePro/Plugins", isDirectory: true) + } // MARK: - Registry Metadata @@ -134,7 +143,6 @@ final class PluginManager { } private func migrateDisabledPluginsKey() { - let defaults = UserDefaults.standard if let legacy = defaults.stringArray(forKey: Self.legacyDisabledPluginsKey) { if defaults.stringArray(forKey: Self.disabledPluginsKey) == nil { defaults.set(legacy, forKey: Self.disabledPluginsKey) @@ -317,7 +325,7 @@ final class PluginManager { } } - if let builtInDir = builtInPluginsDir { + if let builtInDir = builtInPluginsURL { discoverPlugins(from: builtInDir, source: .builtIn) removeUserInstalledDuplicates(builtInDir: builtInDir) } From e1088a3c348a50452aa38f70911272ed33a48858 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 30 Apr 2026 23:10:27 +0700 Subject: [PATCH 17/29] test: rewrite storage tests to use isolated instances --- .../Core/Storage/QueryHistoryManager.swift | 8 +--- .../DatabaseManagerVersionTests.swift | 2 +- .../Core/Database/MultiConnectionTests.swift | 2 +- .../Core/Plugins/PluginLazyLoadingTests.swift | 2 +- .../Storage/AppSettingsStorageTests.swift | 28 +++-------- ...nnectionStorageAdditionalFieldsTests.swift | 27 +++++++---- .../ConnectionStoragePersistenceTests.swift | 35 +++++++------- .../Core/Storage/GroupStorageTests.swift | 14 ++++-- .../Storage/QueryHistoryManagerTests.swift | 26 +++++++--- .../Storage/QueryHistoryStorageTests.swift | 19 ++++++-- .../Core/Storage/SafeModeMigrationTests.swift | 47 ++++++++++++------- 11 files changed, 121 insertions(+), 89 deletions(-) diff --git a/TablePro/Core/Storage/QueryHistoryManager.swift b/TablePro/Core/Storage/QueryHistoryManager.swift index 99a14567e..aa6e1928d 100644 --- a/TablePro/Core/Storage/QueryHistoryManager.swift +++ b/TablePro/Core/Storage/QueryHistoryManager.swift @@ -5,12 +5,8 @@ final class QueryHistoryManager { private let storage: QueryHistoryStorage - init(isolatedStorage: QueryHistoryStorage) { - self.storage = isolatedStorage - } - - private init() { - self.storage = QueryHistoryStorage.shared + init(storage: QueryHistoryStorage = .shared) { + self.storage = storage } @MainActor diff --git a/TableProTests/Core/Database/DatabaseManagerVersionTests.swift b/TableProTests/Core/Database/DatabaseManagerVersionTests.swift index dc901d077..eb8f6791e 100644 --- a/TableProTests/Core/Database/DatabaseManagerVersionTests.swift +++ b/TableProTests/Core/Database/DatabaseManagerVersionTests.swift @@ -9,7 +9,7 @@ import Foundation import Testing @testable import TablePro -@Suite("DatabaseManager Version Counters") +@Suite("DatabaseManager Version Counters", .serialized) @MainActor struct DatabaseManagerVersionTests { private func makeSession(id: UUID = UUID()) -> (UUID, ConnectionSession) { diff --git a/TableProTests/Core/Database/MultiConnectionTests.swift b/TableProTests/Core/Database/MultiConnectionTests.swift index 5543c0e7e..60a2b39bc 100644 --- a/TableProTests/Core/Database/MultiConnectionTests.swift +++ b/TableProTests/Core/Database/MultiConnectionTests.swift @@ -9,7 +9,7 @@ import Testing // MARK: - DatabaseManager Multi-Session Isolation -@Suite("DatabaseManager Multi-Session Isolation") +@Suite("DatabaseManager Multi-Session Isolation", .serialized) @MainActor struct DatabaseManagerMultiSessionTests { @Test("Multiple sessions coexist independently") diff --git a/TableProTests/Core/Plugins/PluginLazyLoadingTests.swift b/TableProTests/Core/Plugins/PluginLazyLoadingTests.swift index 7d34ac3d8..37e46d9d8 100644 --- a/TableProTests/Core/Plugins/PluginLazyLoadingTests.swift +++ b/TableProTests/Core/Plugins/PluginLazyLoadingTests.swift @@ -9,7 +9,7 @@ import Foundation import Testing @testable import TablePro -@Suite("Plugin Lazy Loading") +@Suite("Plugin Lazy Loading", .serialized) @MainActor struct PluginLazyLoadingTests { @Test("loadPendingPlugins is idempotent when called multiple times") diff --git a/TableProTests/Core/Storage/AppSettingsStorageTests.swift b/TableProTests/Core/Storage/AppSettingsStorageTests.swift index 2ab15aa42..7dca862e6 100644 --- a/TableProTests/Core/Storage/AppSettingsStorageTests.swift +++ b/TableProTests/Core/Storage/AppSettingsStorageTests.swift @@ -11,36 +11,33 @@ import Testing @Suite("AppSettingsStorage - Last Open Connection IDs") struct AppSettingsStorageLastOpenConnectionTests { - private let storage = AppSettingsStorage.shared + private let storage: AppSettingsStorage + private let defaults: UserDefaults - /// Clean state before and after each test to prevent cross-test pollution. - private func cleanup() { - storage.saveLastOpenConnectionIds([]) + init() { + let suiteName = "com.TablePro.tests.AppSettingsStorage.\(UUID().uuidString)" + self.defaults = UserDefaults(suiteName: suiteName)! + self.storage = AppSettingsStorage(userDefaults: defaults) } @Test("saveLastOpenConnectionIds + loadLastOpenConnectionIds round-trip") func roundTrip() { - cleanup() let ids = [UUID(), UUID(), UUID()] storage.saveLastOpenConnectionIds(ids) let loaded = storage.loadLastOpenConnectionIds() #expect(loaded == ids) - - cleanup() } @Test("loadLastOpenConnectionIds returns empty when nothing saved") func returnsEmptyWhenNothingSaved() { - cleanup() let loaded = storage.loadLastOpenConnectionIds() #expect(loaded.isEmpty) } @Test("saveLastOpenConnectionIds with empty array clears state") func emptyArrayClearsState() { - cleanup() let ids = [UUID()] storage.saveLastOpenConnectionIds(ids) storage.saveLastOpenConnectionIds([]) @@ -51,7 +48,6 @@ struct AppSettingsStorageLastOpenConnectionTests { @Test("saveLastOpenConnectionIds overwrites previous state") func overwritesPreviousState() { - cleanup() let first = [UUID(), UUID()] let second = [UUID()] @@ -60,39 +56,29 @@ struct AppSettingsStorageLastOpenConnectionTests { let loaded = storage.loadLastOpenConnectionIds() #expect(loaded == second) - - cleanup() } @Test("loadLastOpenConnectionIds ignores malformed UUID strings") func ignoresMalformedUUIDs() { - cleanup() - // Write raw strings directly to verify the load method handles bad data let validId = UUID() storage.saveLastOpenConnectionIds([validId]) - // Overwrite with raw strings including invalid UUIDs - UserDefaults.standard.set( + defaults.set( [validId.uuidString, "not-a-uuid", "also-bad"], forKey: "com.TablePro.settings.lastOpenConnectionIds" ) let loaded = storage.loadLastOpenConnectionIds() #expect(loaded == [validId]) - - cleanup() } @Test("Preserves order of connection IDs") func preservesOrder() { - cleanup() let ids = (0..<5).map { _ in UUID() } storage.saveLastOpenConnectionIds(ids) let loaded = storage.loadLastOpenConnectionIds() #expect(loaded == ids) - - cleanup() } } diff --git a/TableProTests/Core/Storage/ConnectionStorageAdditionalFieldsTests.swift b/TableProTests/Core/Storage/ConnectionStorageAdditionalFieldsTests.swift index 346e45189..ca830af4b 100644 --- a/TableProTests/Core/Storage/ConnectionStorageAdditionalFieldsTests.swift +++ b/TableProTests/Core/Storage/ConnectionStorageAdditionalFieldsTests.swift @@ -7,10 +7,26 @@ import Foundation import Testing @testable import TablePro -@Suite("ConnectionStorage Additional Fields", .serialized) +@Suite("ConnectionStorage Additional Fields") @MainActor struct ConnectionStorageAdditionalFieldsTests { - private let storage = ConnectionStorage.shared + private let storage: ConnectionStorage + private let suiteName: String + private let defaults: UserDefaults + + init() { + let unique = UUID().uuidString + let fileURL = FileManager.default.temporaryDirectory + .appendingPathComponent("tablepro-tests") + .appendingPathComponent("connections_\(unique).json") + try? FileManager.default.createDirectory( + at: fileURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + self.suiteName = "com.TablePro.tests.ConnectionStorage.\(unique)" + self.defaults = UserDefaults(suiteName: suiteName)! + self.storage = ConnectionStorage(fileURL: fileURL, userDefaults: defaults) + } @Test("round-trip preserves MongoDB-specific fields") func roundTripMongoFields() { @@ -138,9 +154,6 @@ struct ConnectionStorageAdditionalFieldsTests { @Test("save and reload clears cache and round-trips correctly") func saveAndReloadClearsCache() { - let original = storage.loadConnections() - defer { storage.saveConnections(original) } - let id = UUID() let connection = DatabaseConnection( id: id, @@ -153,10 +166,6 @@ struct ConnectionStorageAdditionalFieldsTests { storage.saveConnections([connection]) - // Force cache invalidation by saving again with the same data - let data = UserDefaults.standard.data(forKey: "com.TablePro.connections") - #expect(data != nil) - let loaded = storage.loadConnections() #expect(loaded.count == 1) #expect(loaded[0].mongoAuthSource == "testdb") diff --git a/TableProTests/Core/Storage/ConnectionStoragePersistenceTests.swift b/TableProTests/Core/Storage/ConnectionStoragePersistenceTests.swift index 23a540025..ec69cf79b 100644 --- a/TableProTests/Core/Storage/ConnectionStoragePersistenceTests.swift +++ b/TableProTests/Core/Storage/ConnectionStoragePersistenceTests.swift @@ -7,38 +7,40 @@ import Foundation import Testing @testable import TablePro -@Suite("ConnectionStorage Persistence", .serialized) +@Suite("ConnectionStorage Persistence") @MainActor struct ConnectionStoragePersistenceTests { - private let storage = ConnectionStorage.shared + private let storage: ConnectionStorage + private let defaults: UserDefaults + + init() { + let unique = UUID().uuidString + let fileURL = FileManager.default.temporaryDirectory + .appendingPathComponent("tablepro-tests") + .appendingPathComponent("connections_\(unique).json") + try? FileManager.default.createDirectory( + at: fileURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + let suiteName = "com.TablePro.tests.ConnectionStorage.\(unique)" + self.defaults = UserDefaults(suiteName: suiteName)! + self.storage = ConnectionStorage(fileURL: fileURL, userDefaults: defaults) + } - @Test("loading empty storage does not write back to UserDefaults") + @Test("loading empty storage does not write back") func loadEmptyDoesNotWrite() { - let original = storage.loadConnections() - defer { storage.saveConnections(original) } - - // Clear all connections - storage.saveConnections([]) - - // Force cache clear by saving then loading let loaded = storage.loadConnections() #expect(loaded.isEmpty) - // Add a connection directly, bypassing cache let connection = DatabaseConnection(name: "Persistence Test") storage.addConnection(connection) - defer { storage.deleteConnection(connection) } - // Loading again should return the connection, not overwrite with empty let reloaded = storage.loadConnections() #expect(reloaded.contains { $0.id == connection.id }) } @Test("round-trip save and load preserves connections") func roundTripSaveLoad() { - let original = storage.loadConnections() - defer { storage.saveConnections(original) } - let connection = DatabaseConnection( name: "Round Trip Test", host: "127.0.0.1", @@ -52,6 +54,5 @@ struct ConnectionStoragePersistenceTests { #expect(loaded.count == 1) #expect(loaded.first?.id == connection.id) #expect(loaded.first?.name == "Round Trip Test") - #expect(loaded.first?.host == "127.0.0.1") } } diff --git a/TableProTests/Core/Storage/GroupStorageTests.swift b/TableProTests/Core/Storage/GroupStorageTests.swift index b0c888dda..b465f1ea6 100644 --- a/TableProTests/Core/Storage/GroupStorageTests.swift +++ b/TableProTests/Core/Storage/GroupStorageTests.swift @@ -8,16 +8,22 @@ import XCTest @MainActor final class GroupStorageTests: XCTestCase { - private let storage = GroupStorage.shared - private let testKey = "com.TablePro.groups" + private var defaults: UserDefaults! + private var suiteName: String! + private var storage: GroupStorage! override func setUp() { super.setUp() - UserDefaults.standard.removeObject(forKey: testKey) + suiteName = "com.TablePro.tests.GroupStorage.\(UUID().uuidString)" + defaults = UserDefaults(suiteName: suiteName)! + storage = GroupStorage(userDefaults: defaults) } override func tearDown() { - UserDefaults.standard.removeObject(forKey: testKey) + defaults.removePersistentDomain(forName: suiteName) + defaults = nil + suiteName = nil + storage = nil super.tearDown() } diff --git a/TableProTests/Core/Storage/QueryHistoryManagerTests.swift b/TableProTests/Core/Storage/QueryHistoryManagerTests.swift index 38e9644a9..5806dc492 100644 --- a/TableProTests/Core/Storage/QueryHistoryManagerTests.swift +++ b/TableProTests/Core/Storage/QueryHistoryManagerTests.swift @@ -9,10 +9,22 @@ import Foundation @testable import TablePro import Testing -@Suite("QueryHistoryManager", .serialized) +@Suite("QueryHistoryManager") struct QueryHistoryManagerTests { - private let manager = QueryHistoryManager.shared - private let storage = QueryHistoryStorage.shared + private let manager: QueryHistoryManager + private let storage: QueryHistoryStorage + + init() { + self.storage = Self.makeIsolatedStorage() + self.manager = QueryHistoryManager(storage: self.storage) + } + + static func makeIsolatedStorage() -> QueryHistoryStorage { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("tablepro-tests") + .appendingPathComponent("query_history_\(UUID().uuidString).db") + return QueryHistoryStorage(databaseURL: url, removeDatabaseOnDeinit: true) + } private func makeAndInsertEntry( query: String = "SELECT 1", @@ -78,8 +90,8 @@ struct QueryHistoryManagerTests { @Test("clearAllHistory clears and returns true") func clearAllHistoryReturnsTrue() async { - let isolatedStorage = QueryHistoryStorage(isolatedForTesting: true) - let isolatedManager = QueryHistoryManager(isolatedStorage: isolatedStorage) + let isolatedStorage = QueryHistoryManagerTests.makeIsolatedStorage() + let isolatedManager = QueryHistoryManager(storage: isolatedStorage) _ = await isolatedStorage.addHistory(QueryHistoryEntry( query: "SELECT clear_test", connectionId: UUID(), @@ -114,8 +126,8 @@ struct QueryHistoryManagerTests { @Test("clearAllHistory posts queryHistoryDidUpdate notification") func clearAllHistoryPostsNotification() async { - let isolatedStorage = QueryHistoryStorage(isolatedForTesting: true) - let isolatedManager = QueryHistoryManager(isolatedStorage: isolatedStorage) + let isolatedStorage = QueryHistoryManagerTests.makeIsolatedStorage() + let isolatedManager = QueryHistoryManager(storage: isolatedStorage) _ = await isolatedStorage.addHistory(QueryHistoryEntry( query: "SELECT notify_test", connectionId: UUID(), diff --git a/TableProTests/Core/Storage/QueryHistoryStorageTests.swift b/TableProTests/Core/Storage/QueryHistoryStorageTests.swift index abfeac488..4cf28817a 100644 --- a/TableProTests/Core/Storage/QueryHistoryStorageTests.swift +++ b/TableProTests/Core/Storage/QueryHistoryStorageTests.swift @@ -10,9 +10,20 @@ import Foundation @testable import TablePro import Testing -@Suite("QueryHistoryStorage", .serialized) +@Suite("QueryHistoryStorage") struct QueryHistoryStorageTests { - private let storage = QueryHistoryStorage.shared + private let storage: QueryHistoryStorage + + init() { + self.storage = Self.makeIsolatedStorage() + } + + static func makeIsolatedStorage() -> QueryHistoryStorage { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("tablepro-tests") + .appendingPathComponent("query_history_\(UUID().uuidString).db") + return QueryHistoryStorage(databaseURL: url, removeDatabaseOnDeinit: true) + } private func makeEntry( id: UUID = UUID(), @@ -38,7 +49,7 @@ struct QueryHistoryStorageTests { @Test("Isolated instance initializes without deadlock") func isolatedInitDoesNotDeadlock() async { - let isolated = QueryHistoryStorage(isolatedForTesting: true) + let isolated = Self.makeIsolatedStorage() let entries = await isolated.fetchHistory() #expect(entries.isEmpty) } @@ -185,7 +196,7 @@ struct QueryHistoryStorageTests { @Test("clearAllHistory removes all entries") func clearAllHistoryRemovesAll() async { - let isolated = QueryHistoryStorage(isolatedForTesting: true) + let isolated = Self.makeIsolatedStorage() _ = await isolated.addHistory(makeEntry(query: "SELECT clear_test")) let result = await isolated.clearAllHistory() #expect(result == true) diff --git a/TableProTests/Core/Storage/SafeModeMigrationTests.swift b/TableProTests/Core/Storage/SafeModeMigrationTests.swift index 2789aaf4a..9c398e64f 100644 --- a/TableProTests/Core/Storage/SafeModeMigrationTests.swift +++ b/TableProTests/Core/Storage/SafeModeMigrationTests.swift @@ -12,6 +12,23 @@ import Testing @Suite("SafeModeMigration") @MainActor struct SafeModeMigrationTests { + private let storage: ConnectionStorage + private let defaults: UserDefaults + + init() { + let unique = UUID().uuidString + let fileURL = FileManager.default.temporaryDirectory + .appendingPathComponent("tablepro-tests") + .appendingPathComponent("connections_\(unique).json") + try? FileManager.default.createDirectory( + at: fileURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + let suiteName = "com.TablePro.tests.ConnectionStorage.\(unique)" + self.defaults = UserDefaults(suiteName: suiteName)! + self.storage = ConnectionStorage(fileURL: fileURL, userDefaults: defaults) + } + // MARK: - Round-Trip Through ConnectionStorage API @Test("DatabaseConnection with silent level survives save and load cycle") @@ -23,10 +40,9 @@ struct SafeModeMigrationTests { safeModeLevel: .silent ) - ConnectionStorage.shared.addConnection(connection) - defer { ConnectionStorage.shared.deleteConnection(connection) } + storage.addConnection(connection) - let found = ConnectionStorage.shared.loadConnections().first { $0.id == id } + let found = storage.loadConnections().first { $0.id == id } #expect(found?.safeModeLevel == .silent) } @@ -39,10 +55,9 @@ struct SafeModeMigrationTests { safeModeLevel: .alert ) - ConnectionStorage.shared.addConnection(connection) - defer { ConnectionStorage.shared.deleteConnection(connection) } + storage.addConnection(connection) - let found = ConnectionStorage.shared.loadConnections().first { $0.id == id } + let found = storage.loadConnections().first { $0.id == id } #expect(found?.safeModeLevel == .alert) } @@ -55,10 +70,9 @@ struct SafeModeMigrationTests { safeModeLevel: .alertFull ) - ConnectionStorage.shared.addConnection(connection) - defer { ConnectionStorage.shared.deleteConnection(connection) } + storage.addConnection(connection) - let found = ConnectionStorage.shared.loadConnections().first { $0.id == id } + let found = storage.loadConnections().first { $0.id == id } #expect(found?.safeModeLevel == .alertFull) } @@ -71,10 +85,9 @@ struct SafeModeMigrationTests { safeModeLevel: .safeMode ) - ConnectionStorage.shared.addConnection(connection) - defer { ConnectionStorage.shared.deleteConnection(connection) } + storage.addConnection(connection) - let found = ConnectionStorage.shared.loadConnections().first { $0.id == id } + let found = storage.loadConnections().first { $0.id == id } #expect(found?.safeModeLevel == .safeMode) } @@ -87,10 +100,9 @@ struct SafeModeMigrationTests { safeModeLevel: .safeModeFull ) - ConnectionStorage.shared.addConnection(connection) - defer { ConnectionStorage.shared.deleteConnection(connection) } + storage.addConnection(connection) - let found = ConnectionStorage.shared.loadConnections().first { $0.id == id } + let found = storage.loadConnections().first { $0.id == id } #expect(found?.safeModeLevel == .safeModeFull) } @@ -103,10 +115,9 @@ struct SafeModeMigrationTests { safeModeLevel: .readOnly ) - ConnectionStorage.shared.addConnection(connection) - defer { ConnectionStorage.shared.deleteConnection(connection) } + storage.addConnection(connection) - let found = ConnectionStorage.shared.loadConnections().first { $0.id == id } + let found = storage.loadConnections().first { $0.id == id } #expect(found?.safeModeLevel == .readOnly) } From 93984516de7cbd48dc559e8012137c9ccde957fd Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 30 Apr 2026 23:11:02 +0700 Subject: [PATCH 18/29] docs: add changelog entry for Apple-pattern singleton refactor --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b9a66750..2ba33baab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Storage and manager classes (GroupStorage, AppSettingsStorage, ConnectionStorage, SyncMetadataStorage, QueryHistoryStorage, DatabaseManager, PluginManager) accept dependencies via init for test isolation, matching Apple's URLSession and UserDefaults convention. Production callers using `.shared` are unchanged. Tests now construct isolated instances with per-test temp paths and UserDefaults suites, so the test scheme runs in parallel again. - Create Database dialog is now driver-driven. Each driver discovers its own valid options (PostgreSQL queries `pg_collation` and `pg_database`, MySQL/MariaDB query `information_schema.character_sets`/`collations`). The hardcoded macOS-flavored locale list is gone. Engines that don't support creation hide the Create button instead of failing on click. - Introduced TableRows, Row, and Delta value types in TablePro/Models/Query/ as the foundation for the data grid row model rewrite. No callers migrated yet (Phase C.1 of the DataGrid refactor). - DataGrid columns and cells refactored to use a persistent column pool and typed cell view hierarchy. CPU usage on table switch reduced significantly through proper NSTableView reuse pool retention. From dbf11ef8c78c497fffdf761395ba8658b30993d1 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 30 Apr 2026 23:26:34 +0700 Subject: [PATCH 19/29] refactor(storage): inject database URL into SQLFavoriteStorage --- .../Core/Storage/SQLFavoriteManager.swift | 9 +-- .../Core/Storage/SQLFavoriteStorage.swift | 65 +++++++------------ 2 files changed, 27 insertions(+), 47 deletions(-) diff --git a/TablePro/Core/Storage/SQLFavoriteManager.swift b/TablePro/Core/Storage/SQLFavoriteManager.swift index ac23f573e..ca956e828 100644 --- a/TablePro/Core/Storage/SQLFavoriteManager.swift +++ b/TablePro/Core/Storage/SQLFavoriteManager.swift @@ -13,13 +13,8 @@ internal final class SQLFavoriteManager: @unchecked Sendable { private let storage: SQLFavoriteStorage - /// Creates an isolated manager with its own storage. For testing only. - init(isolatedStorage: SQLFavoriteStorage) { - self.storage = isolatedStorage - } - - private init() { - self.storage = SQLFavoriteStorage.shared + init(storage: SQLFavoriteStorage = .shared) { + self.storage = storage } // MARK: - Favorites diff --git a/TablePro/Core/Storage/SQLFavoriteStorage.swift b/TablePro/Core/Storage/SQLFavoriteStorage.swift index e9dfc00e7..2f7acfe23 100644 --- a/TablePro/Core/Storage/SQLFavoriteStorage.swift +++ b/TablePro/Core/Storage/SQLFavoriteStorage.swift @@ -15,20 +15,15 @@ internal final class SQLFavoriteStorage { private let queue = DispatchQueue(label: "com.TablePro.sqlfavorites", qos: .utility) private var db: OpaquePointer? - private static var isRunningTests: Bool { - NSClassFromString("XCTestCase") != nil - } - - private init() { - queue.async { [weak self] in - self?.setupDatabase() - } - } - - #if DEBUG - /// Creates an isolated instance with a unique database file. For testing only. - init(isolatedForTesting: Bool) { - testDatabaseSuffix = isolatedForTesting ? "_\(UUID().uuidString)" : nil + private let databaseURL: URL + private let removeDatabaseOnDeinit: Bool + + init( + databaseURL: URL = SQLFavoriteStorage.defaultDatabaseURL(), + removeDatabaseOnDeinit: Bool = false + ) { + self.databaseURL = databaseURL + self.removeDatabaseOnDeinit = removeDatabaseOnDeinit let semaphore = DispatchSemaphore(value: 0) queue.async { [self] in setupDatabase() @@ -36,20 +31,26 @@ internal final class SQLFavoriteStorage { } semaphore.wait() } - #endif - - private var testDatabaseSuffix: String? - private var dbPath: String? + static func defaultDatabaseURL() -> URL { + let fileManager = FileManager.default + let appSupport = fileManager.urls( + for: .applicationSupportDirectory, in: .userDomainMask + ).first ?? fileManager.temporaryDirectory + let dir = appSupport.appendingPathComponent("TablePro") + try? fileManager.createDirectory(at: dir, withIntermediateDirectories: true) + return dir.appendingPathComponent("sql_favorites.db") + } deinit { if let db = db { sqlite3_close_v2(db) } - if Self.isRunningTests, let dbPath = dbPath { - try? FileManager.default.removeItem(atPath: dbPath) + if removeDatabaseOnDeinit { + let path = databaseURL.path(percentEncoded: false) + try? FileManager.default.removeItem(atPath: path) for suffix in ["-wal", "-shm"] { - try? FileManager.default.removeItem(atPath: dbPath + suffix) + try? FileManager.default.removeItem(atPath: path + suffix) } } } @@ -81,26 +82,10 @@ internal final class SQLFavoriteStorage { // MARK: - Database Setup private func setupDatabase() { - let fileManager = FileManager.default - guard - let appSupport = fileManager.urls( - for: .applicationSupportDirectory, in: .userDomainMask - ).first - else { - Self.logger.error("Unable to access application support directory") - return - } - let tableProDir = appSupport.appendingPathComponent("TablePro") - - try? fileManager.createDirectory(at: tableProDir, withIntermediateDirectories: true) - - let suffix = testDatabaseSuffix ?? "" - let dbFileName = Self.isRunningTests - ? "sql_favorites_test_\(ProcessInfo.processInfo.processIdentifier)\(suffix).db" - : "sql_favorites.db" - let dbPath = tableProDir.appendingPathComponent(dbFileName).path(percentEncoded: false) + let dir = databaseURL.deletingLastPathComponent() + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) - self.dbPath = dbPath + let dbPath = databaseURL.path(percentEncoded: false) if sqlite3_open(dbPath, &db) != SQLITE_OK { Self.logger.error("Error opening database") From a9960e90dbcc1eb8ad9e38c6d2bca6504b894d21 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 30 Apr 2026 23:26:56 +0700 Subject: [PATCH 20/29] refactor(sync): inject metadata storage into SyncChangeTracker --- TablePro/Core/Sync/SyncChangeTracker.swift | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/TablePro/Core/Sync/SyncChangeTracker.swift b/TablePro/Core/Sync/SyncChangeTracker.swift index 64004507b..ee2d724c1 100644 --- a/TablePro/Core/Sync/SyncChangeTracker.swift +++ b/TablePro/Core/Sync/SyncChangeTracker.swift @@ -17,7 +17,8 @@ final class SyncChangeTracker { static let shared = SyncChangeTracker() private static let logger = Logger(subsystem: "com.TablePro", category: "SyncChangeTracker") - private let metadataStorage = SyncMetadataStorage.shared + private let metadataStorage: SyncMetadataStorage + private let notificationCenter: NotificationCenter /// When true, changes are not tracked (used during remote apply to avoid sync loops) private let suppressionLock = OSAllocatedUnfairLock(initialState: false) @@ -27,7 +28,13 @@ final class SyncChangeTracker { set { suppressionLock.withLock { $0 = newValue } } } - private init() {} + init( + metadataStorage: SyncMetadataStorage = .shared, + notificationCenter: NotificationCenter = .default + ) { + self.metadataStorage = metadataStorage + self.notificationCenter = notificationCenter + } // MARK: - Mark Dirty @@ -76,6 +83,6 @@ final class SyncChangeTracker { // MARK: - Private private func postChangeNotification() { - NotificationCenter.default.post(name: .syncChangeTracked, object: self) + notificationCenter.post(name: .syncChangeTracked, object: self) } } From 783236c48d517b8b06d4fce44cf35853de27612c Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 30 Apr 2026 23:28:03 +0700 Subject: [PATCH 21/29] test: rewrite SQLFavoriteStorage and sync-aware tests with injected instances --- .../ConnectionStorageAdditionalFieldsTests.swift | 9 ++++++++- .../ConnectionStoragePersistenceTests.swift | 9 ++++++++- .../Core/Storage/GroupStorageTests.swift | 14 ++++++++++++-- .../Core/Storage/SQLFavoriteStorageTests.swift | 15 +++++++++++++-- 4 files changed, 41 insertions(+), 6 deletions(-) diff --git a/TableProTests/Core/Storage/ConnectionStorageAdditionalFieldsTests.swift b/TableProTests/Core/Storage/ConnectionStorageAdditionalFieldsTests.swift index ca830af4b..fd1574c2f 100644 --- a/TableProTests/Core/Storage/ConnectionStorageAdditionalFieldsTests.swift +++ b/TableProTests/Core/Storage/ConnectionStorageAdditionalFieldsTests.swift @@ -25,7 +25,14 @@ struct ConnectionStorageAdditionalFieldsTests { ) self.suiteName = "com.TablePro.tests.ConnectionStorage.\(unique)" self.defaults = UserDefaults(suiteName: suiteName)! - self.storage = ConnectionStorage(fileURL: fileURL, userDefaults: defaults) + let syncDefaults = UserDefaults(suiteName: "com.TablePro.tests.Sync.\(unique)")! + let metadata = SyncMetadataStorage(userDefaults: syncDefaults) + let tracker = SyncChangeTracker(metadataStorage: metadata) + self.storage = ConnectionStorage( + fileURL: fileURL, + userDefaults: defaults, + syncTracker: tracker + ) } @Test("round-trip preserves MongoDB-specific fields") diff --git a/TableProTests/Core/Storage/ConnectionStoragePersistenceTests.swift b/TableProTests/Core/Storage/ConnectionStoragePersistenceTests.swift index ec69cf79b..98a721aef 100644 --- a/TableProTests/Core/Storage/ConnectionStoragePersistenceTests.swift +++ b/TableProTests/Core/Storage/ConnectionStoragePersistenceTests.swift @@ -24,7 +24,14 @@ struct ConnectionStoragePersistenceTests { ) let suiteName = "com.TablePro.tests.ConnectionStorage.\(unique)" self.defaults = UserDefaults(suiteName: suiteName)! - self.storage = ConnectionStorage(fileURL: fileURL, userDefaults: defaults) + let syncDefaults = UserDefaults(suiteName: "com.TablePro.tests.Sync.\(unique)")! + let metadata = SyncMetadataStorage(userDefaults: syncDefaults) + let tracker = SyncChangeTracker(metadataStorage: metadata) + self.storage = ConnectionStorage( + fileURL: fileURL, + userDefaults: defaults, + syncTracker: tracker + ) } @Test("loading empty storage does not write back") diff --git a/TableProTests/Core/Storage/GroupStorageTests.swift b/TableProTests/Core/Storage/GroupStorageTests.swift index b465f1ea6..3483e6912 100644 --- a/TableProTests/Core/Storage/GroupStorageTests.swift +++ b/TableProTests/Core/Storage/GroupStorageTests.swift @@ -10,19 +10,29 @@ import XCTest final class GroupStorageTests: XCTestCase { private var defaults: UserDefaults! private var suiteName: String! + private var syncDefaults: UserDefaults! + private var syncSuiteName: String! private var storage: GroupStorage! override func setUp() { super.setUp() - suiteName = "com.TablePro.tests.GroupStorage.\(UUID().uuidString)" + let unique = UUID().uuidString + suiteName = "com.TablePro.tests.GroupStorage.\(unique)" defaults = UserDefaults(suiteName: suiteName)! - storage = GroupStorage(userDefaults: defaults) + syncSuiteName = "com.TablePro.tests.Sync.\(unique)" + syncDefaults = UserDefaults(suiteName: syncSuiteName)! + let metadata = SyncMetadataStorage(userDefaults: syncDefaults) + let tracker = SyncChangeTracker(metadataStorage: metadata) + storage = GroupStorage(userDefaults: defaults, syncTracker: tracker) } override func tearDown() { defaults.removePersistentDomain(forName: suiteName) + syncDefaults.removePersistentDomain(forName: syncSuiteName) defaults = nil suiteName = nil + syncDefaults = nil + syncSuiteName = nil storage = nil super.tearDown() } diff --git a/TableProTests/Core/Storage/SQLFavoriteStorageTests.swift b/TableProTests/Core/Storage/SQLFavoriteStorageTests.swift index 432ef8ad2..00492d8f5 100644 --- a/TableProTests/Core/Storage/SQLFavoriteStorageTests.swift +++ b/TableProTests/Core/Storage/SQLFavoriteStorageTests.swift @@ -7,9 +7,20 @@ import Foundation @testable import TablePro import Testing -@Suite("SQLFavoriteStorage", .serialized) +@Suite("SQLFavoriteStorage") struct SQLFavoriteStorageTests { - private let storage = SQLFavoriteStorage(isolatedForTesting: true) + private let storage: SQLFavoriteStorage + + init() { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("tablepro-tests") + .appendingPathComponent("sql_favorites_\(UUID().uuidString).db") + try? FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + self.storage = SQLFavoriteStorage(databaseURL: url, removeDatabaseOnDeinit: true) + } // MARK: - Helpers From 57197a39c8c2995d91c672574b879c18ea69122b Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 30 Apr 2026 23:28:58 +0700 Subject: [PATCH 22/29] refactor(storage): drop dead test-detection branch from QueryHistoryStorage default URL --- TablePro/Core/Storage/QueryHistoryStorage.swift | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/TablePro/Core/Storage/QueryHistoryStorage.swift b/TablePro/Core/Storage/QueryHistoryStorage.swift index 21f8e6bf5..52cd1855a 100644 --- a/TablePro/Core/Storage/QueryHistoryStorage.swift +++ b/TablePro/Core/Storage/QueryHistoryStorage.swift @@ -53,11 +53,7 @@ actor QueryHistoryStorage { ).first ?? fileManager.temporaryDirectory let dir = appSupport.appendingPathComponent("TablePro") try? fileManager.createDirectory(at: dir, withIntermediateDirectories: true) - let isRunningTests = NSClassFromString("XCTestCase") != nil - let fileName = isRunningTests - ? "query_history_test_\(ProcessInfo.processInfo.processIdentifier).db" - : "query_history.db" - return dir.appendingPathComponent(fileName) + return dir.appendingPathComponent("query_history.db") } deinit { From 83aaf13c935f53f7a0d7fd8be5e1c7ef108f83d5 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 30 Apr 2026 23:29:17 +0700 Subject: [PATCH 23/29] docs: add changelog entry for SQLFavoriteStorage and SyncChangeTracker DI --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ba33baab..6c941f122 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 ### Changed - Storage and manager classes (GroupStorage, AppSettingsStorage, ConnectionStorage, SyncMetadataStorage, QueryHistoryStorage, DatabaseManager, PluginManager) accept dependencies via init for test isolation, matching Apple's URLSession and UserDefaults convention. Production callers using `.shared` are unchanged. Tests now construct isolated instances with per-test temp paths and UserDefaults suites, so the test scheme runs in parallel again. +- SQLFavoriteStorage and SyncChangeTracker now accept their dependencies via init (database URL for the favorites store, SyncMetadataStorage and NotificationCenter for the change tracker). Tests injecting a SyncChangeTracker into ConnectionStorage or GroupStorage can now also pass an isolated SyncMetadataStorage so dirty-set writes stop leaking through `.shared`. Replaces the `init(isolatedForTesting:)` and `init(isolatedStorage:)` ad hoc constructors that the rest of the storage classes had already moved past. - Create Database dialog is now driver-driven. Each driver discovers its own valid options (PostgreSQL queries `pg_collation` and `pg_database`, MySQL/MariaDB query `information_schema.character_sets`/`collations`). The hardcoded macOS-flavored locale list is gone. Engines that don't support creation hide the Create button instead of failing on click. - Introduced TableRows, Row, and Delta value types in TablePro/Models/Query/ as the foundation for the data grid row model rewrite. No callers migrated yet (Phase C.1 of the DataGrid refactor). - DataGrid columns and cells refactored to use a persistent column pool and typed cell view hierarchy. CPU usage on table switch reduced significantly through proper NSTableView reuse pool retention. From 0baaaa9061aeaa781e3a0c81e0bea139127e101e Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 30 Apr 2026 23:38:04 +0700 Subject: [PATCH 24/29] fix(database): expose injected dependencies as internal for cross-file extensions --- TablePro/Core/Database/DatabaseManager.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/TablePro/Core/Database/DatabaseManager.swift b/TablePro/Core/Database/DatabaseManager.swift index 3f1fa4bac..83bded437 100644 --- a/TablePro/Core/Database/DatabaseManager.swift +++ b/TablePro/Core/Database/DatabaseManager.swift @@ -17,9 +17,9 @@ final class DatabaseManager { static let shared = DatabaseManager() internal static let logger = Logger(subsystem: "com.TablePro", category: "DatabaseManager") - @ObservationIgnored private let connectionStorage: ConnectionStorage - @ObservationIgnored private let appSettingsStorage: AppSettingsStorage - @ObservationIgnored private let pluginManager: PluginManager + @ObservationIgnored internal let connectionStorage: ConnectionStorage + @ObservationIgnored internal let appSettingsStorage: AppSettingsStorage + @ObservationIgnored internal let pluginManager: PluginManager /// All active connection sessions internal(set) var activeSessions: [UUID: ConnectionSession] = [:] { From 5a04dd838255923e926e07e5fce13e347bbe788d Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 1 May 2026 00:20:53 +0700 Subject: [PATCH 25/29] fix(storage): persist connection deletions before firing sync notification --- TablePro/Core/Storage/ConnectionStorage.swift | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/TablePro/Core/Storage/ConnectionStorage.swift b/TablePro/Core/Storage/ConnectionStorage.swift index 0224b4e45..5b3b6c459 100644 --- a/TablePro/Core/Storage/ConnectionStorage.swift +++ b/TablePro/Core/Storage/ConnectionStorage.swift @@ -157,12 +157,12 @@ final class ConnectionStorage { /// Delete a connection func deleteConnection(_ connection: DatabaseConnection) { - if !connection.localOnly { - syncTracker.markDeleted(.connection, id: connection.id.uuidString) - } var connections = loadConnections() connections.removeAll { $0.id == connection.id } saveConnections(connections) + if !connection.localOnly { + syncTracker.markDeleted(.connection, id: connection.id.uuidString) + } deletePassword(for: connection.id) deleteSSHPassword(for: connection.id) deleteKeyPassphrase(for: connection.id) @@ -178,13 +178,13 @@ final class ConnectionStorage { /// Batch-delete multiple connections and clean up their Keychain entries func deleteConnections(_ connectionsToDelete: [DatabaseConnection]) { - for conn in connectionsToDelete where !conn.localOnly { - syncTracker.markDeleted(.connection, id: conn.id.uuidString) - } let idsToDelete = Set(connectionsToDelete.map(\.id)) var all = loadConnections() all.removeAll { idsToDelete.contains($0.id) } saveConnections(all) + for conn in connectionsToDelete where !conn.localOnly { + syncTracker.markDeleted(.connection, id: conn.id.uuidString) + } for conn in connectionsToDelete { deletePassword(for: conn.id) deleteSSHPassword(for: conn.id) From cc4040a57075604fcee3e06c4b7f107703f65c09 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 1 May 2026 00:20:57 +0700 Subject: [PATCH 26/29] docs: add changelog entry for connection delete fix and tighten storage refactor entries --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c941f122..75704a8a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,8 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Storage and manager classes (GroupStorage, AppSettingsStorage, ConnectionStorage, SyncMetadataStorage, QueryHistoryStorage, DatabaseManager, PluginManager) accept dependencies via init for test isolation, matching Apple's URLSession and UserDefaults convention. Production callers using `.shared` are unchanged. Tests now construct isolated instances with per-test temp paths and UserDefaults suites, so the test scheme runs in parallel again. -- SQLFavoriteStorage and SyncChangeTracker now accept their dependencies via init (database URL for the favorites store, SyncMetadataStorage and NotificationCenter for the change tracker). Tests injecting a SyncChangeTracker into ConnectionStorage or GroupStorage can now also pass an isolated SyncMetadataStorage so dirty-set writes stop leaking through `.shared`. Replaces the `init(isolatedForTesting:)` and `init(isolatedStorage:)` ad hoc constructors that the rest of the storage classes had already moved past. +- Storage and sync singletons accept dependencies via init for test isolation, matching Apple's URLSession and UserDefaults convention. Production callers using `.shared` are unchanged. - Create Database dialog is now driver-driven. Each driver discovers its own valid options (PostgreSQL queries `pg_collation` and `pg_database`, MySQL/MariaDB query `information_schema.character_sets`/`collations`). The hardcoded macOS-flavored locale list is gone. Engines that don't support creation hide the Create button instead of failing on click. - Introduced TableRows, Row, and Delta value types in TablePro/Models/Query/ as the foundation for the data grid row model rewrite. No callers migrated yet (Phase C.1 of the DataGrid refactor). - DataGrid columns and cells refactored to use a persistent column pool and typed cell view hierarchy. CPU usage on table switch reduced significantly through proper NSTableView reuse pool retention. @@ -76,6 +75,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Redshift Create Database emitted PostgreSQL `LC_COLLATE` syntax which is invalid Redshift grammar. Now emits `COLLATE { CASE_SENSITIVE | CASE_INSENSITIVE }`. - Expand tilde in SSH agent socket and `IdentityAgent` paths so 1Password and similar agents work when configured with `~/...` paths. - Persist group deletions before firing the sync notification, fixing a race that could re-upload deleted groups via iCloud. +- Persist connection deletions before firing the sync notification, fixing the same race for deleted connections. - Refuse to generate SQL when the database dialect cannot be resolved, instead of silently emitting unquoted identifiers. ## [0.36.0] - 2026-04-27 From b2df37943271379421e7d24c55b274abf8f38dd5 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 1 May 2026 00:20:57 +0700 Subject: [PATCH 27/29] docs: correct sync delete ordering invariant --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index dec7d5def..d060211e7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -123,7 +123,7 @@ When adding a new method to the driver protocol: add to `PluginDatabaseDriver` ( These have caused real bugs when violated: -**Sync delete ordering**: In `ConnectionStorage` (and all storage classes), `SyncChangeTracker.markDeleted()` must be called BEFORE `saveConnections()`. The `markDeleted` call fires `postChangeNotification` which can trigger a sync — if `saveConnections` hasn't run yet, the file still has the deleted item and sync may re-add it. +**Sync delete ordering**: In `ConnectionStorage` (and all storage classes), `SyncChangeTracker.markDeleted()` must be called AFTER `saveConnections()`. The `markDeleted` call fires `postChangeNotification` which can trigger a sync. If the file on disk still contains the deleted item when sync runs, it may re-upload the deleted record. Persist first, then notify. **WelcomeViewModel tree rebuild**: The welcome screen renders `treeItems` (grouped/filtered), not `connections` directly. Every mutation to `connections` must call `rebuildTree()` afterward, or the UI won't update. From fc9e0dec62f896438c294a1565630c56a7906dea Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 1 May 2026 00:20:57 +0700 Subject: [PATCH 28/29] style(plugins): add explicit internal access modifier to userPluginsDir --- TablePro/Core/Plugins/PluginManager.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TablePro/Core/Plugins/PluginManager.swift b/TablePro/Core/Plugins/PluginManager.swift index cbf01ad27..8ce577358 100644 --- a/TablePro/Core/Plugins/PluginManager.swift +++ b/TablePro/Core/Plugins/PluginManager.swift @@ -18,7 +18,7 @@ final class PluginManager { @ObservationIgnored private let defaults: UserDefaults @ObservationIgnored private let builtInPluginsURL: URL? - @ObservationIgnored let userPluginsDir: URL + @ObservationIgnored internal let userPluginsDir: URL internal(set) var plugins: [PluginEntry] = [] From a3af3df8b29b7ee2158ae8c4d144facbaad42192 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 1 May 2026 00:26:12 +0700 Subject: [PATCH 29/29] refactor(storage): convert SQLFavoriteStorage to actor --- CHANGELOG.md | 2 +- .../Core/Storage/SQLFavoriteStorage.swift | 899 ++++++++---------- 2 files changed, 387 insertions(+), 514 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 75704a8a2..64f6307c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,7 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Storage and sync singletons accept dependencies via init for test isolation, matching Apple's URLSession and UserDefaults convention. Production callers using `.shared` are unchanged. +- Storage and sync singletons accept dependencies via init for test isolation, matching Apple's URLSession and UserDefaults convention. Production callers using `.shared` are unchanged. `SQLFavoriteStorage` is now an actor so its first access no longer blocks the main thread on SQLite setup. - Create Database dialog is now driver-driven. Each driver discovers its own valid options (PostgreSQL queries `pg_collation` and `pg_database`, MySQL/MariaDB query `information_schema.character_sets`/`collations`). The hardcoded macOS-flavored locale list is gone. Engines that don't support creation hide the Create button instead of failing on click. - Introduced TableRows, Row, and Delta value types in TablePro/Models/Query/ as the foundation for the data grid row model rewrite. No callers migrated yet (Phase C.1 of the DataGrid refactor). - DataGrid columns and cells refactored to use a persistent column pool and typed cell view hierarchy. CPU usage on table switch reduced significantly through proper NSTableView reuse pool retention. diff --git a/TablePro/Core/Storage/SQLFavoriteStorage.swift b/TablePro/Core/Storage/SQLFavoriteStorage.swift index 2f7acfe23..44b987042 100644 --- a/TablePro/Core/Storage/SQLFavoriteStorage.swift +++ b/TablePro/Core/Storage/SQLFavoriteStorage.swift @@ -7,12 +7,10 @@ import Foundation import os import SQLite3 -/// Thread-safe SQLite storage for SQL favorites with FTS5 full-text search -internal final class SQLFavoriteStorage { +internal actor SQLFavoriteStorage { static let shared = SQLFavoriteStorage() private static let logger = Logger(subsystem: "com.TablePro", category: "SQLFavoriteStorage") - private let queue = DispatchQueue(label: "com.TablePro.sqlfavorites", qos: .utility) private var db: OpaquePointer? private let databaseURL: URL @@ -24,12 +22,7 @@ internal final class SQLFavoriteStorage { ) { self.databaseURL = databaseURL self.removeDatabaseOnDeinit = removeDatabaseOnDeinit - let semaphore = DispatchSemaphore(value: 0) - queue.async { [self] in - setupDatabase() - semaphore.signal() - } - semaphore.wait() + setupDatabase() } static func defaultDatabaseURL() -> URL { @@ -55,30 +48,6 @@ internal final class SQLFavoriteStorage { } } - // MARK: - Database Work Helpers - - private func performDatabaseWork(_ work: @escaping () throws -> T) async throws -> T { - try await withCheckedThrowingContinuation { continuation in - queue.async { - do { - let result = try work() - continuation.resume(returning: result) - } catch { - continuation.resume(throwing: error) - } - } - } - } - - private func performDatabaseWork(_ work: @escaping () -> T) async -> T { - await withCheckedContinuation { continuation in - queue.async { - let result = work() - continuation.resume(returning: result) - } - } - } - // MARK: - Database Setup private func setupDatabase() { @@ -105,14 +74,11 @@ internal final class SQLFavoriteStorage { let currentVersion = getUserVersion() if currentVersion < 1 { - // Fresh database — tables already created without is_synced, jump to latest version setUserVersion(2) return } if currentVersion < 2 { - // Remove is_synced column using rename-recreate-copy pattern - // (SQLite < 3.35.0 doesn't support ALTER TABLE DROP COLUMN) execute("ALTER TABLE favorites RENAME TO favorites_old") execute(""" CREATE TABLE IF NOT EXISTS favorites ( @@ -140,7 +106,6 @@ internal final class SQLFavoriteStorage { """) execute("DROP TABLE folders_old") - // Recreate indexes dropped with the old tables execute("CREATE INDEX IF NOT EXISTS idx_favorites_connection ON favorites(connection_id);") execute("CREATE INDEX IF NOT EXISTS idx_favorites_folder ON favorites(folder_id);") execute("CREATE INDEX IF NOT EXISTS idx_favorites_keyword ON favorites(keyword);") @@ -148,7 +113,6 @@ internal final class SQLFavoriteStorage { execute("CREATE INDEX IF NOT EXISTS idx_folders_connection ON folders(connection_id);") execute("CREATE INDEX IF NOT EXISTS idx_folders_parent ON folders(parent_id);") - // Recreate FTS5 triggers referencing the new table execute("DROP TRIGGER IF EXISTS favorites_ai;") execute("DROP TRIGGER IF EXISTS favorites_ad;") execute("DROP TRIGGER IF EXISTS favorites_au;") @@ -169,7 +133,6 @@ internal final class SQLFavoriteStorage { END; """) - // Rebuild FTS5 index to match new table rowids execute("INSERT INTO favorites_fts(favorites_fts) VALUES('rebuild');") setUserVersion(2) @@ -283,632 +246,542 @@ internal final class SQLFavoriteStorage { // MARK: - Favorite Operations - func addFavorite(_ favorite: SQLFavorite) async -> Bool { - let idString = favorite.id.uuidString - let nameString = favorite.name - let queryString = favorite.query - let keywordString = favorite.keyword - let folderIdString = favorite.folderId?.uuidString - let connectionIdString = favorite.connectionId?.uuidString - let sortOrder = Int32(favorite.sortOrder) - let createdAt = favorite.createdAt.timeIntervalSince1970 - let updatedAt = favorite.updatedAt.timeIntervalSince1970 - - return await performDatabaseWork { [weak self] in - guard let self = self else { return false } - - let sql = """ - INSERT INTO favorites (id, name, query, keyword, folder_id, connection_id, sort_order, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?); - """ + func addFavorite(_ favorite: SQLFavorite) -> Bool { + let sql = """ + INSERT INTO favorites (id, name, query, keyword, folder_id, connection_id, sort_order, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?); + """ - var statement: OpaquePointer? - guard sqlite3_prepare_v2(self.db, sql, -1, &statement, nil) == SQLITE_OK else { - return false - } + var statement: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else { + return false + } - defer { sqlite3_finalize(statement) } + defer { sqlite3_finalize(statement) } - let SQLITE_TRANSIENT = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + let SQLITE_TRANSIENT = unsafeBitCast(-1, to: sqlite3_destructor_type.self) - sqlite3_bind_text(statement, 1, idString, -1, SQLITE_TRANSIENT) - sqlite3_bind_text(statement, 2, nameString, -1, SQLITE_TRANSIENT) - sqlite3_bind_text(statement, 3, queryString, -1, SQLITE_TRANSIENT) + sqlite3_bind_text(statement, 1, favorite.id.uuidString, -1, SQLITE_TRANSIENT) + sqlite3_bind_text(statement, 2, favorite.name, -1, SQLITE_TRANSIENT) + sqlite3_bind_text(statement, 3, favorite.query, -1, SQLITE_TRANSIENT) - if let keyword = keywordString { - sqlite3_bind_text(statement, 4, keyword, -1, SQLITE_TRANSIENT) - } else { - sqlite3_bind_null(statement, 4) - } + if let keyword = favorite.keyword { + sqlite3_bind_text(statement, 4, keyword, -1, SQLITE_TRANSIENT) + } else { + sqlite3_bind_null(statement, 4) + } - if let folderId = folderIdString { - sqlite3_bind_text(statement, 5, folderId, -1, SQLITE_TRANSIENT) - } else { - sqlite3_bind_null(statement, 5) - } + if let folderId = favorite.folderId?.uuidString { + sqlite3_bind_text(statement, 5, folderId, -1, SQLITE_TRANSIENT) + } else { + sqlite3_bind_null(statement, 5) + } - if let connectionId = connectionIdString { - sqlite3_bind_text(statement, 6, connectionId, -1, SQLITE_TRANSIENT) - } else { - sqlite3_bind_null(statement, 6) - } + if let connectionId = favorite.connectionId?.uuidString { + sqlite3_bind_text(statement, 6, connectionId, -1, SQLITE_TRANSIENT) + } else { + sqlite3_bind_null(statement, 6) + } - sqlite3_bind_int(statement, 7, sortOrder) - sqlite3_bind_double(statement, 8, createdAt) - sqlite3_bind_double(statement, 9, updatedAt) + sqlite3_bind_int(statement, 7, Int32(favorite.sortOrder)) + sqlite3_bind_double(statement, 8, favorite.createdAt.timeIntervalSince1970) + sqlite3_bind_double(statement, 9, favorite.updatedAt.timeIntervalSince1970) - let result = sqlite3_step(statement) - if result != SQLITE_DONE { - Self.logger.error("Failed to add favorite: \(String(cString: sqlite3_errmsg(self.db)))") - } - return result == SQLITE_DONE + let result = sqlite3_step(statement) + if result != SQLITE_DONE { + Self.logger.error("Failed to add favorite: \(String(cString: sqlite3_errmsg(self.db)))") } + return result == SQLITE_DONE } - func updateFavorite(_ favorite: SQLFavorite) async -> Bool { - let idString = favorite.id.uuidString - let nameString = favorite.name - let queryString = favorite.query - let keywordString = favorite.keyword - let folderIdString = favorite.folderId?.uuidString - let connectionIdString = favorite.connectionId?.uuidString - let sortOrder = Int32(favorite.sortOrder) - let updatedAt = favorite.updatedAt.timeIntervalSince1970 - - return await performDatabaseWork { [weak self] in - guard let self = self else { return false } - - let sql = """ - UPDATE favorites SET name = ?, query = ?, keyword = ?, folder_id = ?, connection_id = ?, sort_order = ?, updated_at = ? - WHERE id = ?; - """ + func updateFavorite(_ favorite: SQLFavorite) -> Bool { + let sql = """ + UPDATE favorites SET name = ?, query = ?, keyword = ?, folder_id = ?, connection_id = ?, sort_order = ?, updated_at = ? + WHERE id = ?; + """ - var statement: OpaquePointer? - guard sqlite3_prepare_v2(self.db, sql, -1, &statement, nil) == SQLITE_OK else { - return false - } + var statement: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else { + return false + } - defer { sqlite3_finalize(statement) } + defer { sqlite3_finalize(statement) } - let SQLITE_TRANSIENT = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + let SQLITE_TRANSIENT = unsafeBitCast(-1, to: sqlite3_destructor_type.self) - sqlite3_bind_text(statement, 1, nameString, -1, SQLITE_TRANSIENT) - sqlite3_bind_text(statement, 2, queryString, -1, SQLITE_TRANSIENT) + sqlite3_bind_text(statement, 1, favorite.name, -1, SQLITE_TRANSIENT) + sqlite3_bind_text(statement, 2, favorite.query, -1, SQLITE_TRANSIENT) - if let keyword = keywordString { - sqlite3_bind_text(statement, 3, keyword, -1, SQLITE_TRANSIENT) - } else { - sqlite3_bind_null(statement, 3) - } + if let keyword = favorite.keyword { + sqlite3_bind_text(statement, 3, keyword, -1, SQLITE_TRANSIENT) + } else { + sqlite3_bind_null(statement, 3) + } - if let folderId = folderIdString { - sqlite3_bind_text(statement, 4, folderId, -1, SQLITE_TRANSIENT) - } else { - sqlite3_bind_null(statement, 4) - } + if let folderId = favorite.folderId?.uuidString { + sqlite3_bind_text(statement, 4, folderId, -1, SQLITE_TRANSIENT) + } else { + sqlite3_bind_null(statement, 4) + } - if let connectionId = connectionIdString { - sqlite3_bind_text(statement, 5, connectionId, -1, SQLITE_TRANSIENT) - } else { - sqlite3_bind_null(statement, 5) - } + if let connectionId = favorite.connectionId?.uuidString { + sqlite3_bind_text(statement, 5, connectionId, -1, SQLITE_TRANSIENT) + } else { + sqlite3_bind_null(statement, 5) + } - sqlite3_bind_int(statement, 6, sortOrder) - sqlite3_bind_double(statement, 7, updatedAt) - sqlite3_bind_text(statement, 8, idString, -1, SQLITE_TRANSIENT) + sqlite3_bind_int(statement, 6, Int32(favorite.sortOrder)) + sqlite3_bind_double(statement, 7, favorite.updatedAt.timeIntervalSince1970) + sqlite3_bind_text(statement, 8, favorite.id.uuidString, -1, SQLITE_TRANSIENT) - let result = sqlite3_step(statement) - return result == SQLITE_DONE - } + return sqlite3_step(statement) == SQLITE_DONE } - func deleteFavorite(id: UUID) async -> Bool { - let idString = id.uuidString - return await performDatabaseWork { [weak self] in - guard let self = self else { return false } - - let sql = "DELETE FROM favorites WHERE id = ?;" - var statement: OpaquePointer? - guard sqlite3_prepare_v2(self.db, sql, -1, &statement, nil) == SQLITE_OK else { - return false - } + func deleteFavorite(id: UUID) -> Bool { + let sql = "DELETE FROM favorites WHERE id = ?;" + var statement: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else { + return false + } - defer { sqlite3_finalize(statement) } + defer { sqlite3_finalize(statement) } - let SQLITE_TRANSIENT = unsafeBitCast(-1, to: sqlite3_destructor_type.self) - sqlite3_bind_text(statement, 1, idString, -1, SQLITE_TRANSIENT) - return sqlite3_step(statement) == SQLITE_DONE - } + let SQLITE_TRANSIENT = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + sqlite3_bind_text(statement, 1, id.uuidString, -1, SQLITE_TRANSIENT) + return sqlite3_step(statement) == SQLITE_DONE } - func deleteFavorites(ids: [UUID]) async -> Bool { + func deleteFavorites(ids: [UUID]) -> Bool { guard !ids.isEmpty else { return true } - let idStrings = ids.map { $0.uuidString } - return await performDatabaseWork { [weak self] in - guard let self = self else { return false } - let placeholders = ids.map { _ in "?" }.joined(separator: ",") - let sql = "DELETE FROM favorites WHERE id IN (\(placeholders));" + let placeholders = ids.map { _ in "?" }.joined(separator: ",") + let sql = "DELETE FROM favorites WHERE id IN (\(placeholders));" - var statement: OpaquePointer? - guard sqlite3_prepare_v2(self.db, sql, -1, &statement, nil) == SQLITE_OK else { - return false - } + var statement: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else { + return false + } - defer { sqlite3_finalize(statement) } + defer { sqlite3_finalize(statement) } - let SQLITE_TRANSIENT = unsafeBitCast(-1, to: sqlite3_destructor_type.self) - for (index, idString) in idStrings.enumerated() { - sqlite3_bind_text(statement, Int32(index + 1), idString, -1, SQLITE_TRANSIENT) - } + let SQLITE_TRANSIENT = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + for (index, id) in ids.enumerated() { + sqlite3_bind_text(statement, Int32(index + 1), id.uuidString, -1, SQLITE_TRANSIENT) + } - let result = sqlite3_step(statement) - if result != SQLITE_DONE { - Self.logger.error("Failed to batch delete favorites: \(String(cString: sqlite3_errmsg(self.db)))") - } - return result == SQLITE_DONE + let result = sqlite3_step(statement) + if result != SQLITE_DONE { + Self.logger.error("Failed to batch delete favorites: \(String(cString: sqlite3_errmsg(self.db)))") } + return result == SQLITE_DONE } - func fetchFavorite(id: UUID) async -> SQLFavorite? { - let idString = id.uuidString - return await performDatabaseWork { [weak self] in - guard let self = self else { return nil } - - let sql = "SELECT id, name, query, keyword, folder_id, connection_id, sort_order, created_at, updated_at FROM favorites WHERE id = ? LIMIT 1;" - var statement: OpaquePointer? - guard sqlite3_prepare_v2(self.db, sql, -1, &statement, nil) == SQLITE_OK else { - return nil - } + func fetchFavorite(id: UUID) -> SQLFavorite? { + let sql = "SELECT id, name, query, keyword, folder_id, connection_id, sort_order, created_at, updated_at FROM favorites WHERE id = ? LIMIT 1;" + var statement: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else { + return nil + } - defer { sqlite3_finalize(statement) } + defer { sqlite3_finalize(statement) } - let SQLITE_TRANSIENT = unsafeBitCast(-1, to: sqlite3_destructor_type.self) - sqlite3_bind_text(statement, 1, idString, -1, SQLITE_TRANSIENT) + let SQLITE_TRANSIENT = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + sqlite3_bind_text(statement, 1, id.uuidString, -1, SQLITE_TRANSIENT) - if sqlite3_step(statement) == SQLITE_ROW { - return self.parseFavorite(from: statement) - } - return nil + if sqlite3_step(statement) == SQLITE_ROW { + return parseFavorite(from: statement) } + return nil } func fetchFavorites( connectionId: UUID? = nil, folderId: UUID? = nil, searchText: String? = nil - ) async -> [SQLFavorite] { + ) -> [SQLFavorite] { let connectionIdString = connectionId?.uuidString let folderIdString = folderId?.uuidString - return await performDatabaseWork { [weak self] in - guard let self = self else { return [] } - - var sql: String - var bindIndex: Int32 = 1 - var hasConnectionFilter = false - var hasFolderFilter = false - - let isJoined: Bool - if let searchText = searchText, !searchText.isEmpty { - sql = """ - SELECT f.id, f.name, f.query, f.keyword, f.folder_id, f.connection_id, f.sort_order, f.created_at, f.updated_at - FROM favorites f - INNER JOIN favorites_fts ON f.rowid = favorites_fts.rowid - WHERE favorites_fts MATCH ? - """ - isJoined = true - - if connectionIdString != nil { - sql += " AND (f.connection_id IS NULL OR f.connection_id = ?)" - hasConnectionFilter = true - } - - if folderIdString != nil { - sql += " AND f.folder_id = ?" - hasFolderFilter = true - } - } else { - sql = """ - SELECT id, name, query, keyword, folder_id, connection_id, sort_order, created_at, updated_at - FROM favorites - """ - isJoined = false - - var whereClauses: [String] = [] - - if connectionIdString != nil { - whereClauses.append("(connection_id IS NULL OR connection_id = ?)") - hasConnectionFilter = true - } - - if folderIdString != nil { - whereClauses.append("folder_id = ?") - hasFolderFilter = true - } - - if !whereClauses.isEmpty { - sql += " WHERE " + whereClauses.joined(separator: " AND ") - } - } - - sql += isJoined ? " ORDER BY f.sort_order ASC, f.name ASC;" : " ORDER BY sort_order ASC, name ASC;" + var sql: String + var bindIndex: Int32 = 1 + var hasConnectionFilter = false + var hasFolderFilter = false + + let isJoined: Bool + if let searchText = searchText, !searchText.isEmpty { + sql = """ + SELECT f.id, f.name, f.query, f.keyword, f.folder_id, f.connection_id, f.sort_order, f.created_at, f.updated_at + FROM favorites f + INNER JOIN favorites_fts ON f.rowid = favorites_fts.rowid + WHERE favorites_fts MATCH ? + """ + isJoined = true - var statement: OpaquePointer? - guard sqlite3_prepare_v2(self.db, sql, -1, &statement, nil) == SQLITE_OK else { - return [] + if connectionIdString != nil { + sql += " AND (f.connection_id IS NULL OR f.connection_id = ?)" + hasConnectionFilter = true } - defer { sqlite3_finalize(statement) } + if folderIdString != nil { + sql += " AND f.folder_id = ?" + hasFolderFilter = true + } + } else { + sql = """ + SELECT id, name, query, keyword, folder_id, connection_id, sort_order, created_at, updated_at + FROM favorites + """ + isJoined = false - let SQLITE_TRANSIENT = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + var whereClauses: [String] = [] - if let searchText = searchText, !searchText.isEmpty { - let sanitized = "\"\(searchText.replacingOccurrences(of: "\"", with: "\"\""))\"" - sqlite3_bind_text(statement, bindIndex, sanitized, -1, SQLITE_TRANSIENT) - bindIndex += 1 + if connectionIdString != nil { + whereClauses.append("(connection_id IS NULL OR connection_id = ?)") + hasConnectionFilter = true } - if let connId = connectionIdString, hasConnectionFilter { - sqlite3_bind_text(statement, bindIndex, connId, -1, SQLITE_TRANSIENT) - bindIndex += 1 + if folderIdString != nil { + whereClauses.append("folder_id = ?") + hasFolderFilter = true } - if let foldId = folderIdString, hasFolderFilter { - sqlite3_bind_text(statement, bindIndex, foldId, -1, SQLITE_TRANSIENT) - bindIndex += 1 + if !whereClauses.isEmpty { + sql += " WHERE " + whereClauses.joined(separator: " AND ") } + } - var favorites: [SQLFavorite] = [] - while sqlite3_step(statement) == SQLITE_ROW { - if let favorite = self.parseFavorite(from: statement) { - favorites.append(favorite) - } - } + sql += isJoined ? " ORDER BY f.sort_order ASC, f.name ASC;" : " ORDER BY sort_order ASC, name ASC;" - return favorites + var statement: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else { + return [] } - } - // MARK: - Folder Operations - - func addFolder(_ folder: SQLFavoriteFolder) async -> Bool { - let idString = folder.id.uuidString - let nameString = folder.name - let parentIdString = folder.parentId?.uuidString - let connectionIdString = folder.connectionId?.uuidString - let sortOrder = Int32(folder.sortOrder) - let createdAt = folder.createdAt.timeIntervalSince1970 - let updatedAt = folder.updatedAt.timeIntervalSince1970 - - return await performDatabaseWork { [weak self] in - guard let self = self else { return false } - - let sql = """ - INSERT INTO folders (id, name, parent_id, connection_id, sort_order, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?); - """ + defer { sqlite3_finalize(statement) } - var statement: OpaquePointer? - guard sqlite3_prepare_v2(self.db, sql, -1, &statement, nil) == SQLITE_OK else { - return false - } + let SQLITE_TRANSIENT = unsafeBitCast(-1, to: sqlite3_destructor_type.self) - defer { sqlite3_finalize(statement) } + if let searchText = searchText, !searchText.isEmpty { + let sanitized = "\"\(searchText.replacingOccurrences(of: "\"", with: "\"\""))\"" + sqlite3_bind_text(statement, bindIndex, sanitized, -1, SQLITE_TRANSIENT) + bindIndex += 1 + } - let SQLITE_TRANSIENT = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + if let connId = connectionIdString, hasConnectionFilter { + sqlite3_bind_text(statement, bindIndex, connId, -1, SQLITE_TRANSIENT) + bindIndex += 1 + } - sqlite3_bind_text(statement, 1, idString, -1, SQLITE_TRANSIENT) - sqlite3_bind_text(statement, 2, nameString, -1, SQLITE_TRANSIENT) + if let foldId = folderIdString, hasFolderFilter { + sqlite3_bind_text(statement, bindIndex, foldId, -1, SQLITE_TRANSIENT) + bindIndex += 1 + } - if let parentId = parentIdString { - sqlite3_bind_text(statement, 3, parentId, -1, SQLITE_TRANSIENT) - } else { - sqlite3_bind_null(statement, 3) + var favorites: [SQLFavorite] = [] + while sqlite3_step(statement) == SQLITE_ROW { + if let favorite = parseFavorite(from: statement) { + favorites.append(favorite) } + } - if let connectionId = connectionIdString { - sqlite3_bind_text(statement, 4, connectionId, -1, SQLITE_TRANSIENT) - } else { - sqlite3_bind_null(statement, 4) - } + return favorites + } + + // MARK: - Folder Operations - sqlite3_bind_int(statement, 5, sortOrder) - sqlite3_bind_double(statement, 6, createdAt) - sqlite3_bind_double(statement, 7, updatedAt) + func addFolder(_ folder: SQLFavoriteFolder) -> Bool { + let sql = """ + INSERT INTO folders (id, name, parent_id, connection_id, sort_order, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?); + """ - let result = sqlite3_step(statement) - return result == SQLITE_DONE + var statement: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else { + return false } - } - func updateFolder(_ folder: SQLFavoriteFolder) async -> Bool { - let idString = folder.id.uuidString - let nameString = folder.name - let parentIdString = folder.parentId?.uuidString - let connectionIdString = folder.connectionId?.uuidString - let sortOrder = Int32(folder.sortOrder) - let updatedAt = folder.updatedAt.timeIntervalSince1970 + defer { sqlite3_finalize(statement) } - return await performDatabaseWork { [weak self] in - guard let self = self else { return false } + let SQLITE_TRANSIENT = unsafeBitCast(-1, to: sqlite3_destructor_type.self) - let sql = """ - UPDATE folders SET name = ?, parent_id = ?, connection_id = ?, sort_order = ?, updated_at = ? - WHERE id = ?; - """ + sqlite3_bind_text(statement, 1, folder.id.uuidString, -1, SQLITE_TRANSIENT) + sqlite3_bind_text(statement, 2, folder.name, -1, SQLITE_TRANSIENT) - var statement: OpaquePointer? - guard sqlite3_prepare_v2(self.db, sql, -1, &statement, nil) == SQLITE_OK else { - return false - } + if let parentId = folder.parentId?.uuidString { + sqlite3_bind_text(statement, 3, parentId, -1, SQLITE_TRANSIENT) + } else { + sqlite3_bind_null(statement, 3) + } - defer { sqlite3_finalize(statement) } + if let connectionId = folder.connectionId?.uuidString { + sqlite3_bind_text(statement, 4, connectionId, -1, SQLITE_TRANSIENT) + } else { + sqlite3_bind_null(statement, 4) + } - let SQLITE_TRANSIENT = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + sqlite3_bind_int(statement, 5, Int32(folder.sortOrder)) + sqlite3_bind_double(statement, 6, folder.createdAt.timeIntervalSince1970) + sqlite3_bind_double(statement, 7, folder.updatedAt.timeIntervalSince1970) - sqlite3_bind_text(statement, 1, nameString, -1, SQLITE_TRANSIENT) + return sqlite3_step(statement) == SQLITE_DONE + } - if let parentId = parentIdString { - sqlite3_bind_text(statement, 2, parentId, -1, SQLITE_TRANSIENT) - } else { - sqlite3_bind_null(statement, 2) - } + func updateFolder(_ folder: SQLFavoriteFolder) -> Bool { + let sql = """ + UPDATE folders SET name = ?, parent_id = ?, connection_id = ?, sort_order = ?, updated_at = ? + WHERE id = ?; + """ - if let connectionId = connectionIdString { - sqlite3_bind_text(statement, 3, connectionId, -1, SQLITE_TRANSIENT) - } else { - sqlite3_bind_null(statement, 3) - } + var statement: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else { + return false + } - sqlite3_bind_int(statement, 4, sortOrder) - sqlite3_bind_double(statement, 5, updatedAt) - sqlite3_bind_text(statement, 6, idString, -1, SQLITE_TRANSIENT) + defer { sqlite3_finalize(statement) } - let result = sqlite3_step(statement) - return result == SQLITE_DONE + let SQLITE_TRANSIENT = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + + sqlite3_bind_text(statement, 1, folder.name, -1, SQLITE_TRANSIENT) + + if let parentId = folder.parentId?.uuidString { + sqlite3_bind_text(statement, 2, parentId, -1, SQLITE_TRANSIENT) + } else { + sqlite3_bind_null(statement, 2) } + + if let connectionId = folder.connectionId?.uuidString { + sqlite3_bind_text(statement, 3, connectionId, -1, SQLITE_TRANSIENT) + } else { + sqlite3_bind_null(statement, 3) + } + + sqlite3_bind_int(statement, 4, Int32(folder.sortOrder)) + sqlite3_bind_double(statement, 5, folder.updatedAt.timeIntervalSince1970) + sqlite3_bind_text(statement, 6, folder.id.uuidString, -1, SQLITE_TRANSIENT) + + return sqlite3_step(statement) == SQLITE_DONE } - func deleteFolder(id: UUID) async -> Bool { + func deleteFolder(id: UUID) -> Bool { let idString = id.uuidString - return await performDatabaseWork { [weak self] in - guard let self = self else { return false } - guard sqlite3_exec(self.db, "BEGIN IMMEDIATE;", nil, nil, nil) == SQLITE_OK else { - return false - } + guard sqlite3_exec(db, "BEGIN IMMEDIATE;", nil, nil, nil) == SQLITE_OK else { + return false + } - let SQLITE_TRANSIENT = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + let SQLITE_TRANSIENT = unsafeBitCast(-1, to: sqlite3_destructor_type.self) - // Find the parent_id of the folder being deleted - let findParentSQL = "SELECT parent_id FROM folders WHERE id = ?;" - var findStatement: OpaquePointer? - guard sqlite3_prepare_v2(self.db, findParentSQL, -1, &findStatement, nil) == SQLITE_OK else { - sqlite3_exec(self.db, "ROLLBACK;", nil, nil, nil) - return false - } + let findParentSQL = "SELECT parent_id FROM folders WHERE id = ?;" + var findStatement: OpaquePointer? + guard sqlite3_prepare_v2(db, findParentSQL, -1, &findStatement, nil) == SQLITE_OK else { + sqlite3_exec(db, "ROLLBACK;", nil, nil, nil) + return false + } - sqlite3_bind_text(findStatement, 1, idString, -1, SQLITE_TRANSIENT) + sqlite3_bind_text(findStatement, 1, idString, -1, SQLITE_TRANSIENT) - var parentId: String? - if sqlite3_step(findStatement) == SQLITE_ROW { - parentId = sqlite3_column_text(findStatement, 0).map { String(cString: $0) } - } - sqlite3_finalize(findStatement) - - // Move child favorites to the parent folder - let moveFavoritesSQL = "UPDATE favorites SET folder_id = ? WHERE folder_id = ?;" - var moveFavStatement: OpaquePointer? - if sqlite3_prepare_v2(self.db, moveFavoritesSQL, -1, &moveFavStatement, nil) == SQLITE_OK { - if let parentId = parentId { - sqlite3_bind_text(moveFavStatement, 1, parentId, -1, SQLITE_TRANSIENT) - } else { - sqlite3_bind_null(moveFavStatement, 1) - } - sqlite3_bind_text(moveFavStatement, 2, idString, -1, SQLITE_TRANSIENT) - let moveFavResult = sqlite3_step(moveFavStatement) - sqlite3_finalize(moveFavStatement) - if moveFavResult != SQLITE_DONE { - sqlite3_exec(self.db, "ROLLBACK;", nil, nil, nil) - return false - } + var parentId: String? + if sqlite3_step(findStatement) == SQLITE_ROW { + parentId = sqlite3_column_text(findStatement, 0).map { String(cString: $0) } + } + sqlite3_finalize(findStatement) + + let moveFavoritesSQL = "UPDATE favorites SET folder_id = ? WHERE folder_id = ?;" + var moveFavStatement: OpaquePointer? + if sqlite3_prepare_v2(db, moveFavoritesSQL, -1, &moveFavStatement, nil) == SQLITE_OK { + if let parentId = parentId { + sqlite3_bind_text(moveFavStatement, 1, parentId, -1, SQLITE_TRANSIENT) } else { - sqlite3_finalize(moveFavStatement) - sqlite3_exec(self.db, "ROLLBACK;", nil, nil, nil) + sqlite3_bind_null(moveFavStatement, 1) + } + sqlite3_bind_text(moveFavStatement, 2, idString, -1, SQLITE_TRANSIENT) + let moveFavResult = sqlite3_step(moveFavStatement) + sqlite3_finalize(moveFavStatement) + if moveFavResult != SQLITE_DONE { + sqlite3_exec(db, "ROLLBACK;", nil, nil, nil) return false } + } else { + sqlite3_finalize(moveFavStatement) + sqlite3_exec(db, "ROLLBACK;", nil, nil, nil) + return false + } - // Move child subfolders to the parent folder - let moveSubfoldersSQL = "UPDATE folders SET parent_id = ? WHERE parent_id = ?;" - var moveSubStatement: OpaquePointer? - if sqlite3_prepare_v2(self.db, moveSubfoldersSQL, -1, &moveSubStatement, nil) == SQLITE_OK { - if let parentId = parentId { - sqlite3_bind_text(moveSubStatement, 1, parentId, -1, SQLITE_TRANSIENT) - } else { - sqlite3_bind_null(moveSubStatement, 1) - } - sqlite3_bind_text(moveSubStatement, 2, idString, -1, SQLITE_TRANSIENT) - let moveSubResult = sqlite3_step(moveSubStatement) - sqlite3_finalize(moveSubStatement) - if moveSubResult != SQLITE_DONE { - sqlite3_exec(self.db, "ROLLBACK;", nil, nil, nil) - return false - } + let moveSubfoldersSQL = "UPDATE folders SET parent_id = ? WHERE parent_id = ?;" + var moveSubStatement: OpaquePointer? + if sqlite3_prepare_v2(db, moveSubfoldersSQL, -1, &moveSubStatement, nil) == SQLITE_OK { + if let parentId = parentId { + sqlite3_bind_text(moveSubStatement, 1, parentId, -1, SQLITE_TRANSIENT) } else { - sqlite3_finalize(moveSubStatement) - sqlite3_exec(self.db, "ROLLBACK;", nil, nil, nil) - return false + sqlite3_bind_null(moveSubStatement, 1) } - - // Delete the folder - let deleteSQL = "DELETE FROM folders WHERE id = ?;" - var deleteStatement: OpaquePointer? - guard sqlite3_prepare_v2(self.db, deleteSQL, -1, &deleteStatement, nil) == SQLITE_OK else { - sqlite3_exec(self.db, "ROLLBACK;", nil, nil, nil) + sqlite3_bind_text(moveSubStatement, 2, idString, -1, SQLITE_TRANSIENT) + let moveSubResult = sqlite3_step(moveSubStatement) + sqlite3_finalize(moveSubStatement) + if moveSubResult != SQLITE_DONE { + sqlite3_exec(db, "ROLLBACK;", nil, nil, nil) return false } + } else { + sqlite3_finalize(moveSubStatement) + sqlite3_exec(db, "ROLLBACK;", nil, nil, nil) + return false + } - sqlite3_bind_text(deleteStatement, 1, idString, -1, SQLITE_TRANSIENT) - let result = sqlite3_step(deleteStatement) - sqlite3_finalize(deleteStatement) + let deleteSQL = "DELETE FROM folders WHERE id = ?;" + var deleteStatement: OpaquePointer? + guard sqlite3_prepare_v2(db, deleteSQL, -1, &deleteStatement, nil) == SQLITE_OK else { + sqlite3_exec(db, "ROLLBACK;", nil, nil, nil) + return false + } - if result == SQLITE_DONE { - sqlite3_exec(self.db, "COMMIT;", nil, nil, nil) - } else { - sqlite3_exec(self.db, "ROLLBACK;", nil, nil, nil) - } + sqlite3_bind_text(deleteStatement, 1, idString, -1, SQLITE_TRANSIENT) + let result = sqlite3_step(deleteStatement) + sqlite3_finalize(deleteStatement) - return result == SQLITE_DONE + if result == SQLITE_DONE { + sqlite3_exec(db, "COMMIT;", nil, nil, nil) + } else { + sqlite3_exec(db, "ROLLBACK;", nil, nil, nil) } + + return result == SQLITE_DONE } - func fetchFolders(connectionId: UUID? = nil) async -> [SQLFavoriteFolder] { + func fetchFolders(connectionId: UUID? = nil) -> [SQLFavoriteFolder] { let connectionIdString = connectionId?.uuidString - return await performDatabaseWork { [weak self] in - guard let self = self else { return [] } - - var sql = """ - SELECT id, name, parent_id, connection_id, sort_order, created_at, updated_at - FROM folders - """ + var sql = """ + SELECT id, name, parent_id, connection_id, sort_order, created_at, updated_at + FROM folders + """ - if connectionIdString != nil { - sql += " WHERE (connection_id IS NULL OR connection_id = ?)" - } + if connectionIdString != nil { + sql += " WHERE (connection_id IS NULL OR connection_id = ?)" + } - sql += " ORDER BY sort_order ASC, name ASC;" + sql += " ORDER BY sort_order ASC, name ASC;" - var statement: OpaquePointer? - guard sqlite3_prepare_v2(self.db, sql, -1, &statement, nil) == SQLITE_OK else { - return [] - } + var statement: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else { + return [] + } - defer { sqlite3_finalize(statement) } + defer { sqlite3_finalize(statement) } - if let connId = connectionIdString { - let SQLITE_TRANSIENT = unsafeBitCast(-1, to: sqlite3_destructor_type.self) - sqlite3_bind_text(statement, 1, connId, -1, SQLITE_TRANSIENT) - } + if let connId = connectionIdString { + let SQLITE_TRANSIENT = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + sqlite3_bind_text(statement, 1, connId, -1, SQLITE_TRANSIENT) + } - var folders: [SQLFavoriteFolder] = [] - while sqlite3_step(statement) == SQLITE_ROW { - if let folder = self.parseFolder(from: statement) { - folders.append(folder) - } + var folders: [SQLFavoriteFolder] = [] + while sqlite3_step(statement) == SQLITE_ROW { + if let folder = parseFolder(from: statement) { + folders.append(folder) } - - return folders } + + return folders } // MARK: - Keyword Support - func fetchKeywordMap(connectionId: UUID? = nil) async -> [String: (name: String, query: String)] { + func fetchKeywordMap(connectionId: UUID? = nil) -> [String: (name: String, query: String)] { let connectionIdString = connectionId?.uuidString - return await performDatabaseWork { [weak self] in - guard let self = self else { return [:] } - - var sql = """ - SELECT keyword, name, query FROM favorites - WHERE keyword IS NOT NULL - """ + var sql = """ + SELECT keyword, name, query FROM favorites + WHERE keyword IS NOT NULL + """ - if connectionIdString != nil { - sql += " AND (connection_id IS NULL OR connection_id = ?)" - } + if connectionIdString != nil { + sql += " AND (connection_id IS NULL OR connection_id = ?)" + } - sql += ";" + sql += ";" - var statement: OpaquePointer? - guard sqlite3_prepare_v2(self.db, sql, -1, &statement, nil) == SQLITE_OK else { - return [:] - } + var statement: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else { + return [:] + } - defer { sqlite3_finalize(statement) } + defer { sqlite3_finalize(statement) } - if let connId = connectionIdString { - let SQLITE_TRANSIENT = unsafeBitCast(-1, to: sqlite3_destructor_type.self) - sqlite3_bind_text(statement, 1, connId, -1, SQLITE_TRANSIENT) - } + if let connId = connectionIdString { + let SQLITE_TRANSIENT = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + sqlite3_bind_text(statement, 1, connId, -1, SQLITE_TRANSIENT) + } - var map: [String: (name: String, query: String)] = [:] - while sqlite3_step(statement) == SQLITE_ROW { - guard let keyword = sqlite3_column_text(statement, 0).map({ String(cString: $0) }), - let name = sqlite3_column_text(statement, 1).map({ String(cString: $0) }), - let query = sqlite3_column_text(statement, 2).map({ String(cString: $0) }) - else { - continue - } - map[keyword] = (name: name, query: query) + var map: [String: (name: String, query: String)] = [:] + while sqlite3_step(statement) == SQLITE_ROW { + guard let keyword = sqlite3_column_text(statement, 0).map({ String(cString: $0) }), + let name = sqlite3_column_text(statement, 1).map({ String(cString: $0) }), + let query = sqlite3_column_text(statement, 2).map({ String(cString: $0) }) + else { + continue } - - return map + map[keyword] = (name: name, query: query) } + + return map } func isKeywordAvailable( _ keyword: String, connectionId: UUID?, excludingFavoriteId: UUID? = nil - ) async -> Bool { + ) -> Bool { let connectionIdString = connectionId?.uuidString let excludeIdString = excludingFavoriteId?.uuidString - return await performDatabaseWork { [weak self] in - guard let self = self else { return false } + var sql: String + var bindIndex: Int32 = 1 - var sql: String - var bindIndex: Int32 = 1 + if connectionIdString != nil { + sql = """ + SELECT COUNT(*) FROM favorites + WHERE keyword = ? + AND (connection_id IS NULL OR connection_id = ?) + """ + } else { + sql = """ + SELECT COUNT(*) FROM favorites + WHERE keyword = ? + AND connection_id IS NULL + """ + } - if connectionIdString != nil { - sql = """ - SELECT COUNT(*) FROM favorites - WHERE keyword = ? - AND (connection_id IS NULL OR connection_id = ?) - """ - } else { - sql = """ - SELECT COUNT(*) FROM favorites - WHERE keyword = ? - AND connection_id IS NULL - """ - } + if excludeIdString != nil { + sql += " AND id != ?" + } - if excludeIdString != nil { - sql += " AND id != ?" - } + sql += ";" - sql += ";" + var statement: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else { + return false + } - var statement: OpaquePointer? - guard sqlite3_prepare_v2(self.db, sql, -1, &statement, nil) == SQLITE_OK else { - return false - } + defer { sqlite3_finalize(statement) } - defer { sqlite3_finalize(statement) } + let SQLITE_TRANSIENT = unsafeBitCast(-1, to: sqlite3_destructor_type.self) - let SQLITE_TRANSIENT = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + sqlite3_bind_text(statement, bindIndex, keyword, -1, SQLITE_TRANSIENT) + bindIndex += 1 - sqlite3_bind_text(statement, bindIndex, keyword, -1, SQLITE_TRANSIENT) + if let connId = connectionIdString { + sqlite3_bind_text(statement, bindIndex, connId, -1, SQLITE_TRANSIENT) bindIndex += 1 + } - if let connId = connectionIdString { - sqlite3_bind_text(statement, bindIndex, connId, -1, SQLITE_TRANSIENT) - bindIndex += 1 - } - - if let excludeId = excludeIdString { - sqlite3_bind_text(statement, bindIndex, excludeId, -1, SQLITE_TRANSIENT) - } + if let excludeId = excludeIdString { + sqlite3_bind_text(statement, bindIndex, excludeId, -1, SQLITE_TRANSIENT) + } - if sqlite3_step(statement) == SQLITE_ROW { - return sqlite3_column_int(statement, 0) == 0 - } - return false + if sqlite3_step(statement) == SQLITE_ROW { + return sqlite3_column_int(statement, 0) == 0 } + return false } // MARK: - Parsing Helpers