diff --git a/TablePro.xcodeproj/project.pbxproj b/TablePro.xcodeproj/project.pbxproj index 1daee862..f552dc24 100644 --- a/TablePro.xcodeproj/project.pbxproj +++ b/TablePro.xcodeproj/project.pbxproj @@ -3012,6 +3012,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; + HEADER_SEARCH_PATHS = "$(SRCROOT)/TablePro/Core/SSH/CLibSSH2/include"; MACOSX_DEPLOYMENT_TARGET = 26.2; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.ngoquocdat.TableProTests; @@ -3020,6 +3021,7 @@ STRING_CATALOG_GENERATE_SYMBOLS = NO; SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_INCLUDE_PATHS = "$(SRCROOT)/TablePro/Core/SSH/CLibSSH2"; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/TablePro.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/TablePro"; @@ -3034,6 +3036,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; + HEADER_SEARCH_PATHS = "$(SRCROOT)/TablePro/Core/SSH/CLibSSH2/include"; MACOSX_DEPLOYMENT_TARGET = 26.2; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.ngoquocdat.TableProTests; @@ -3042,6 +3045,7 @@ STRING_CATALOG_GENERATE_SYMBOLS = NO; SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_INCLUDE_PATHS = "$(SRCROOT)/TablePro/Core/SSH/CLibSSH2"; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/TablePro.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/TablePro"; diff --git a/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift b/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift index daac0997..d4ca5e19 100644 --- a/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift +++ b/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift @@ -83,7 +83,8 @@ enum SessionStateFactory { case .query: tabMgr.addTab( initialQuery: payload.initialQuery, - databaseName: payload.databaseName ?? connection.database + databaseName: payload.databaseName ?? connection.database, + sourceFileURL: payload.sourceFileURL ) } } diff --git a/TablePro/Core/Services/Infrastructure/WindowLifecycleMonitor.swift b/TablePro/Core/Services/Infrastructure/WindowLifecycleMonitor.swift index 23ead600..a29f6ce9 100644 --- a/TablePro/Core/Services/Infrastructure/WindowLifecycleMonitor.swift +++ b/TablePro/Core/Services/Infrastructure/WindowLifecycleMonitor.swift @@ -22,6 +22,7 @@ internal final class WindowLifecycleMonitor { } private var entries: [UUID: Entry] = [:] + private var sourceFileWindows: [URL: UUID] = [:] private init() {} @@ -66,6 +67,7 @@ internal final class WindowLifecycleMonitor { /// Remove the UUID mapping for a window. internal func unregisterWindow(for windowId: UUID) { + unregisterSourceFiles(for: windowId) guard let entry = entries.removeValue(forKey: windowId) else { return } if let observer = entry.observer { @@ -147,6 +149,29 @@ internal final class WindowLifecycleMonitor { entries[windowId]?.isPreview = isPreview } + // MARK: - Source File Tracking + + internal func registerSourceFile(_ url: URL, windowId: UUID) { + sourceFileWindows[url] = windowId + } + + internal func unregisterSourceFile(_ url: URL) { + sourceFileWindows.removeValue(forKey: url) + } + + internal func unregisterSourceFiles(for windowId: UUID) { + sourceFileWindows = sourceFileWindows.filter { $0.value != windowId } + } + + internal func window(forSourceFile url: URL) -> NSWindow? { + guard let windowId = sourceFileWindows[url] else { return nil } + guard let window = entries[windowId]?.window else { + sourceFileWindows.removeValue(forKey: url) + return nil + } + return window + } + // MARK: - Private /// Remove entries whose window has already been deallocated. @@ -172,6 +197,7 @@ internal final class WindowLifecycleMonitor { if let observer = entry.observer { NotificationCenter.default.removeObserver(observer) } + unregisterSourceFiles(for: windowId) entries.removeValue(forKey: windowId) let hasRemainingWindows = entries.values.contains { diff --git a/TablePro/Models/Query/EditorTabPayload.swift b/TablePro/Models/Query/EditorTabPayload.swift index 85e8243b..6cbc623a 100644 --- a/TablePro/Models/Query/EditorTabPayload.swift +++ b/TablePro/Models/Query/EditorTabPayload.swift @@ -33,6 +33,8 @@ internal struct EditorTabPayload: Codable, Hashable { internal let isPreview: Bool /// Initial filter state (for FK navigation — pre-applies a WHERE filter) internal let initialFilterState: TabFilterState? + /// Source file URL for .sql files opened from disk (used for deduplication) + internal let sourceFileURL: URL? internal init( id: UUID = UUID(), @@ -45,7 +47,8 @@ internal struct EditorTabPayload: Codable, Hashable { showStructure: Bool = false, skipAutoExecute: Bool = false, isPreview: Bool = false, - initialFilterState: TabFilterState? = nil + initialFilterState: TabFilterState? = nil, + sourceFileURL: URL? = nil ) { self.id = id self.connectionId = connectionId @@ -58,6 +61,7 @@ internal struct EditorTabPayload: Codable, Hashable { self.skipAutoExecute = skipAutoExecute self.isPreview = isPreview self.initialFilterState = initialFilterState + self.sourceFileURL = sourceFileURL } internal init(from decoder: Decoder) throws { @@ -73,6 +77,7 @@ internal struct EditorTabPayload: Codable, Hashable { skipAutoExecute = try container.decodeIfPresent(Bool.self, forKey: .skipAutoExecute) ?? false isPreview = try container.decodeIfPresent(Bool.self, forKey: .isPreview) ?? false initialFilterState = try container.decodeIfPresent(TabFilterState.self, forKey: .initialFilterState) + sourceFileURL = try container.decodeIfPresent(URL.self, forKey: .sourceFileURL) } /// Whether this payload is a "connection-only" payload — just a connectionId @@ -95,5 +100,6 @@ internal struct EditorTabPayload: Codable, Hashable { self.skipAutoExecute = skipAutoExecute self.isPreview = false self.initialFilterState = nil + self.sourceFileURL = tab.sourceFileURL } } diff --git a/TablePro/Models/Query/QueryTab.swift b/TablePro/Models/Query/QueryTab.swift index 1ea83512..aaceddd1 100644 --- a/TablePro/Models/Query/QueryTab.swift +++ b/TablePro/Models/Query/QueryTab.swift @@ -23,7 +23,8 @@ struct PersistedTab: Codable { let tabType: TabType let tableName: String? var isView: Bool = false - var databaseName: String = "" // Database context for this tab (for multi-database restore) + var databaseName: String = "" + var sourceFileURL: URL? } /// Stores pending changes for a tab (used to preserve state when switching tabs) @@ -358,6 +359,9 @@ struct QueryTab: Identifiable, Equatable { // Whether this tab is a preview (temporary) tab that gets replaced on next navigation var isPreview: Bool + // Source file URL for .sql files opened from disk (used for deduplication) + var sourceFileURL: URL? + // Version counter incremented when resultRows changes (used for sort caching) var resultVersion: Int @@ -395,6 +399,7 @@ struct QueryTab: Identifiable, Equatable { self.filterState = TabFilterState() self.columnLayout = ColumnLayoutState() self.isPreview = false + self.sourceFileURL = nil self.resultVersion = 0 self.metadataVersion = 0 } @@ -427,6 +432,7 @@ struct QueryTab: Identifiable, Equatable { self.filterState = TabFilterState() self.columnLayout = ColumnLayoutState() self.isPreview = false + self.sourceFileURL = persisted.sourceFileURL self.resultVersion = 0 self.metadataVersion = 0 } @@ -488,7 +494,8 @@ struct QueryTab: Identifiable, Equatable { tabType: tabType, tableName: tableName, isView: isView, - databaseName: databaseName + databaseName: databaseName, + sourceFileURL: sourceFileURL ) } @@ -537,18 +544,27 @@ final class QueryTabManager { // MARK: - Tab Management - func addTab(initialQuery: String? = nil, title: String? = nil, databaseName: String = "") { + func addTab(initialQuery: String? = nil, title: String? = nil, databaseName: String = "", sourceFileURL: URL? = nil) { + if let sourceFileURL, + let existingIndex = tabs.firstIndex(where: { $0.sourceFileURL == sourceFileURL }) { + if let query = initialQuery { + tabs[existingIndex].query = query + } + selectedTabId = tabs[existingIndex].id + return + } + let queryCount = tabs.count(where: { $0.tabType == .query }) let tabTitle = title ?? "Query \(queryCount + 1)" var newTab = QueryTab(title: tabTitle, tabType: .query) - // If initialQuery provided, use it; otherwise tab starts empty if let query = initialQuery { newTab.query = query - newTab.hasUserInteraction = true // Mark as having content + newTab.hasUserInteraction = true } newTab.databaseName = databaseName + newTab.sourceFileURL = sourceFileURL tabs.append(newTab) selectedTabId = newTab.id } diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index b0e2f183..9b1dc281 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -574,6 +574,11 @@ final class MainContentCommandActions { Task { @MainActor in for url in urls { + if let existingWindow = WindowLifecycleMonitor.shared.window(forSourceFile: url) { + existingWindow.makeKeyAndOrderFront(nil) + continue + } + let content = await Task.detached(priority: .userInitiated) { () -> String? in do { return try String(contentsOf: url, encoding: .utf8) @@ -587,7 +592,8 @@ final class MainContentCommandActions { let payload = EditorTabPayload( connectionId: connection.id, tabType: .query, - initialQuery: content + initialQuery: content, + sourceFileURL: url ) WindowOpener.shared.openNativeTab(payload) } diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index 12237493..f68c3b85 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -511,6 +511,9 @@ struct MainContentView: View { coordinator.needsLazyLoad = true } } + if let sourceURL = payload.sourceFileURL { + WindowLifecycleMonitor.shared.registerSourceFile(sourceURL, windowId: windowId) + } return } diff --git a/TableProTests/Core/Plugins/PluginModelsTests.swift b/TableProTests/Core/Plugins/PluginModelsTests.swift index 07116b86..44ffe39b 100644 --- a/TableProTests/Core/Plugins/PluginModelsTests.swift +++ b/TableProTests/Core/Plugins/PluginModelsTests.swift @@ -8,52 +8,61 @@ import TableProPluginKit import Testing @testable import TablePro -@Suite("PluginEntry Computed Properties — Fallback Behavior") -struct PluginEntryFallbackTests { +@Suite("PluginEntry Computed Properties") +struct PluginEntryTests { - private func makeNonPluginEntry() -> PluginEntry { + private func makeEntry( + databaseTypeId: String? = nil, + additionalTypeIds: [String] = [], + pluginIconName: String = "puzzlepiece", + defaultPort: Int? = nil + ) -> PluginEntry { PluginEntry( - id: "test.non-plugin", + id: "test.plugin", bundle: Bundle.main, url: Bundle.main.bundleURL, source: .builtIn, - name: "Non-Plugin Bundle", + name: "Test Plugin", version: "1.0.0", - pluginDescription: "A bundle whose principalClass is not a DriverPlugin", + pluginDescription: "A test plugin", capabilities: [.databaseDriver], - isEnabled: true + isEnabled: true, + databaseTypeId: databaseTypeId, + additionalTypeIds: additionalTypeIds, + pluginIconName: pluginIconName, + defaultPort: defaultPort ) } - @Test("driverPlugin returns nil for a non-plugin bundle") - func driverPluginReturnsNil() { - let entry = makeNonPluginEntry() - #expect(entry.driverPlugin == nil) - } - - @Test("iconName falls back to puzzlepiece when driverPlugin is nil") - func iconNameFallback() { - let entry = makeNonPluginEntry() - #expect(entry.iconName == "puzzlepiece") - } - - @Test("databaseTypeId returns nil when driverPlugin is nil") + @Test("databaseTypeId returns nil when not set") func databaseTypeIdNil() { - let entry = makeNonPluginEntry() + let entry = makeEntry() #expect(entry.databaseTypeId == nil) } - @Test("additionalTypeIds returns empty array when driverPlugin is nil") + @Test("databaseTypeId returns value when set") + func databaseTypeIdSet() { + let entry = makeEntry(databaseTypeId: "MySQL") + #expect(entry.databaseTypeId == "MySQL") + } + + @Test("additionalTypeIds returns empty array by default") func additionalTypeIdsEmpty() { - let entry = makeNonPluginEntry() + let entry = makeEntry() #expect(entry.additionalTypeIds.isEmpty) } - @Test("defaultPort returns nil when driverPlugin is nil") + @Test("defaultPort returns nil when not set") func defaultPortNil() { - let entry = makeNonPluginEntry() + let entry = makeEntry() #expect(entry.defaultPort == nil) } + + @Test("pluginIconName returns provided value") + func pluginIconName() { + let entry = makeEntry(pluginIconName: "mysql-icon") + #expect(entry.pluginIconName == "mysql-icon") + } } @Suite("PluginSource Enum") @@ -82,7 +91,11 @@ struct PluginEntryIdentityTests { version: "0.1.0", pluginDescription: "", capabilities: [], - isEnabled: false + isEnabled: false, + databaseTypeId: nil, + additionalTypeIds: [], + pluginIconName: "puzzlepiece", + defaultPort: nil ) #expect(entry.id == "com.example.test-plugin") } diff --git a/TableProTests/Core/SSH/SSHTunnelErrorTests.swift b/TableProTests/Core/SSH/SSHTunnelErrorTests.swift new file mode 100644 index 00000000..b369652a --- /dev/null +++ b/TableProTests/Core/SSH/SSHTunnelErrorTests.swift @@ -0,0 +1,61 @@ +// +// SSHTunnelErrorTests.swift +// TableProTests +// +// Tests for SSHTunnelError descriptions and isLocalPortBindFailure classification. +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("SSHTunnelError") +struct SSHTunnelErrorTests { + // MARK: - Port Bind Failure Classification + + @Test("isLocalPortBindFailure detects 'already in use' pattern") + func bindFailureAlreadyInUse() { + #expect(SSHTunnelManager.isLocalPortBindFailure("Address already in use")) + } + + @Test("isLocalPortBindFailure is case-insensitive") + func bindFailureCaseInsensitive() { + #expect(SSHTunnelManager.isLocalPortBindFailure("ADDRESS ALREADY IN USE")) + } + + @Test("isLocalPortBindFailure returns false for unrelated SSH errors") + func nonBindFailures() { + #expect(!SSHTunnelManager.isLocalPortBindFailure("Permission denied")) + #expect(!SSHTunnelManager.isLocalPortBindFailure("Connection refused")) + #expect(!SSHTunnelManager.isLocalPortBindFailure("Host key verification failed")) + #expect(!SSHTunnelManager.isLocalPortBindFailure("")) + } + + // MARK: - Error Descriptions + + @Test("SSHTunnelError.noAvailablePort has a localized description") + func noAvailablePortDescription() { + let error = SSHTunnelError.noAvailablePort + #expect(error.errorDescription != nil) + #expect(error.errorDescription?.isEmpty == false) + } + + @Test("SSHTunnelError.authenticationFailed has a localized description") + func authenticationFailedDescription() { + let error = SSHTunnelError.authenticationFailed + #expect(error.errorDescription != nil) + } + + @Test("SSHTunnelError.tunnelAlreadyExists includes connection ID in description") + func tunnelAlreadyExistsDescription() { + let id = UUID() + let error = SSHTunnelError.tunnelAlreadyExists(id) + #expect(error.errorDescription?.contains(id.uuidString) == true) + } + + @Test("SSHTunnelError.connectionTimeout has a localized description") + func connectionTimeoutDescription() { + let error = SSHTunnelError.connectionTimeout + #expect(error.errorDescription != nil) + } +} diff --git a/TableProTests/Core/SSH/SSHTunnelManagerHealthTests.swift b/TableProTests/Core/SSH/SSHTunnelManagerHealthTests.swift deleted file mode 100644 index c8e834d4..00000000 --- a/TableProTests/Core/SSH/SSHTunnelManagerHealthTests.swift +++ /dev/null @@ -1,120 +0,0 @@ -// -// SSHTunnelManagerHealthTests.swift -// TableProTests -// -// Regression tests for SSHTunnelManager termination handlers (P2-1). -// Validates health check configuration and process tree utilities. -// - -import Foundation -@testable import TablePro -import Testing - -@Suite("SSHTunnelManager Health") -struct SSHTunnelManagerHealthTests { - // MARK: - Descendant Process Tree - - @Test("descendantProcessIds returns root when no children exist") - func descendantProcessIdsRootOnly() { - let result = SSHTunnelManager.descendantProcessIds( - rootProcessId: 42, - parentProcessIds: [100: 200, 300: 400] - ) - #expect(result == [42]) - } - - @Test("descendantProcessIds finds deeply nested children") - func descendantProcessIdsDeeplyNested() { - let result = SSHTunnelManager.descendantProcessIds( - rootProcessId: 1, - parentProcessIds: [2: 1, 3: 2, 4: 3, 5: 4] - ) - #expect(result == [1, 2, 3, 4, 5]) - } - - @Test("descendantProcessIds handles branching process tree") - func descendantProcessIdsBranching() { - let result = SSHTunnelManager.descendantProcessIds( - rootProcessId: 1, - parentProcessIds: [10: 1, 11: 1, 20: 10, 21: 10, 30: 11] - ) - #expect(result == [1, 10, 11, 20, 21, 30]) - } - - @Test("descendantProcessIds with empty parent map returns root only") - func descendantProcessIdsEmptyMap() { - let result = SSHTunnelManager.descendantProcessIds( - rootProcessId: 99, - parentProcessIds: [:] - ) - #expect(result == [99]) - } - - @Test("descendantProcessIds is idempotent across multiple calls") - func descendantProcessIdsIdempotent() { - let parentMap: [Int32: Int32] = [2: 1, 3: 1, 4: 2] - - let result1 = SSHTunnelManager.descendantProcessIds(rootProcessId: 1, parentProcessIds: parentMap) - let result2 = SSHTunnelManager.descendantProcessIds(rootProcessId: 1, parentProcessIds: parentMap) - - #expect(result1 == result2) - } - - // MARK: - Port Bind Failure Classification - - @Test("isLocalPortBindFailure detects all known bind failure patterns") - func bindFailurePatterns() { - #expect(SSHTunnelManager.isLocalPortBindFailure("Address already in use")) - #expect(SSHTunnelManager.isLocalPortBindFailure("cannot listen to port: 60000")) - #expect(SSHTunnelManager.isLocalPortBindFailure("Could not request local forwarding.")) - #expect(SSHTunnelManager.isLocalPortBindFailure("port forwarding failed for listen port 60123")) - } - - @Test("isLocalPortBindFailure is case-insensitive") - func bindFailureCaseInsensitive() { - #expect(SSHTunnelManager.isLocalPortBindFailure("ADDRESS ALREADY IN USE")) - #expect(SSHTunnelManager.isLocalPortBindFailure("Cannot Listen To Port")) - } - - @Test("isLocalPortBindFailure returns false for unrelated SSH errors") - func nonBindFailures() { - #expect(!SSHTunnelManager.isLocalPortBindFailure("Permission denied")) - #expect(!SSHTunnelManager.isLocalPortBindFailure("Connection refused")) - #expect(!SSHTunnelManager.isLocalPortBindFailure("Host key verification failed")) - #expect(!SSHTunnelManager.isLocalPortBindFailure("")) - } - - // MARK: - SSHTunnelError Description - - @Test("SSHTunnelError.noAvailablePort has a localized description") - func noAvailablePortDescription() { - let error = SSHTunnelError.noAvailablePort - #expect(error.errorDescription != nil) - #expect(error.errorDescription?.isEmpty == false) - } - - @Test("SSHTunnelError.authenticationFailed has a localized description") - func authenticationFailedDescription() { - let error = SSHTunnelError.authenticationFailed - #expect(error.errorDescription != nil) - } - - @Test("SSHTunnelError.tunnelAlreadyExists includes connection ID in description") - func tunnelAlreadyExistsDescription() { - let id = UUID() - let error = SSHTunnelError.tunnelAlreadyExists(id) - #expect(error.errorDescription?.contains(id.uuidString) == true) - } - - @Test("SSHTunnelError.connectionTimeout has a localized description") - func connectionTimeoutDescription() { - let error = SSHTunnelError.connectionTimeout - #expect(error.errorDescription != nil) - } - - @Test("SSHTunnelError.sshCommandNotFound has a localized description") - func sshCommandNotFoundDescription() { - let error = SSHTunnelError.sshCommandNotFound - #expect(error.errorDescription != nil) - } -} diff --git a/TableProTests/Core/SSH/SSHTunnelManagerTests.swift b/TableProTests/Core/SSH/SSHTunnelManagerTests.swift deleted file mode 100644 index 3b1f4dbf..00000000 --- a/TableProTests/Core/SSH/SSHTunnelManagerTests.swift +++ /dev/null @@ -1,49 +0,0 @@ -// -// SSHTunnelManagerTests.swift -// TableProTests -// -// Tests for SSH tunnel port binding safeguards. -// - -@testable import TablePro -import Testing - -@Suite("SSHTunnelManager") -struct SSHTunnelManagerTests { - @Test("Ownership checks include child ssh processes") - func descendantProcessIdsIncludeChildren() { - let processTree = SSHTunnelManager.descendantProcessIds( - rootProcessId: 100, - parentProcessIds: [ - 101: 100, - 102: 101, - 200: 999, - ] - ) - - #expect(processTree == [100, 101, 102]) - } - - @Test("Local port bind failures are treated as retryable") - func localPortBindFailuresAreRetryable() { - let errorMessage = """ - bind [127.0.0.1]:60000: Address already in use - channel_setup_fwd_listener_tcpip: cannot listen to port: 60000 - Could not request local forwarding. - """ - - #expect(SSHTunnelManager.isLocalPortBindFailure(errorMessage)) - } - - @Test("Non-bind SSH failures are not retried as port races") - func nonBindFailuresAreNotRetried() { - #expect(SSHTunnelManager.isLocalPortBindFailure("Permission denied (publickey,password).") == false) - #expect(SSHTunnelManager.isLocalPortBindFailure("Connection timed out during banner exchange") == false) - } - - @Test("Generic forwarding failures are treated as retryable bind failures") - func genericForwardingFailuresAreRetryable() { - let errorMessage = "Error: port forwarding failed for listen port 60123" - #expect(SSHTunnelManager.isLocalPortBindFailure(errorMessage)) - } -} diff --git a/TableProTests/Core/Storage/SQLFavoriteStorageTests.swift b/TableProTests/Core/Storage/SQLFavoriteStorageTests.swift index f4e88a85..432ef8ad 100644 --- a/TableProTests/Core/Storage/SQLFavoriteStorageTests.swift +++ b/TableProTests/Core/Storage/SQLFavoriteStorageTests.swift @@ -161,7 +161,7 @@ struct SQLFavoriteStorageTests { // Favorite should now be in parent folder let fetched = await storage.fetchFavorites() let found = fetched.first { $0.id == fav.id } - #expect(found?.folderId == parent.id.uuidString || found?.folderId == parent.id) + #expect(found?.folderId == parent.id) } // MARK: - Keyword diff --git a/TableProTests/Core/Utilities/JsonRowConverterTests.swift b/TableProTests/Core/Utilities/JsonRowConverterTests.swift index 2d09c101..2a84f40d 100644 --- a/TableProTests/Core/Utilities/JsonRowConverterTests.swift +++ b/TableProTests/Core/Utilities/JsonRowConverterTests.swift @@ -3,6 +3,8 @@ // TableProTests // +import Foundation + @testable import TablePro import Testing diff --git a/TableProTests/Models/DataGridSettingsTests.swift b/TableProTests/Models/DataGridSettingsTests.swift deleted file mode 100644 index 6a87f998..00000000 --- a/TableProTests/Models/DataGridSettingsTests.swift +++ /dev/null @@ -1,172 +0,0 @@ -// -// DataGridSettingsTests.swift -// TableProTests -// -// Tests for DataGridSettings fields including font and autoShowInspector. -// - -import AppKit -import Foundation -@testable import TablePro -import Testing - -@Suite("DataGridSettings") -struct DataGridSettingsTests { - @Test("autoShowInspector defaults to false") - func defaultValue() { - let settings = DataGridSettings.default - #expect(settings.autoShowInspector == false) - } - - @Test("autoShowInspector round-trips through Codable") - func codableRoundTrip() throws { - var settings = DataGridSettings.default - settings.autoShowInspector = true - - let data = try JSONEncoder().encode(settings) - let decoded = try JSONDecoder().decode(DataGridSettings.self, from: data) - #expect(decoded.autoShowInspector == true) - } - - @Test("decoding without autoShowInspector key defaults to false") - func backwardsCompatibility() throws { - let oldJson = """ - { - "rowHeight": 24, - "dateFormat": "yyyy-MM-dd HH:mm:ss", - "nullDisplay": "NULL", - "defaultPageSize": 1000, - "showAlternateRows": true - } - """ - let data = oldJson.data(using: .utf8)! - let decoded = try JSONDecoder().decode(DataGridSettings.self, from: data) - #expect(decoded.autoShowInspector == false) - } - - // MARK: - showRowNumbers - - @Test("showRowNumbers defaults to true") - func showRowNumbersDefault() { - let settings = DataGridSettings.default - #expect(settings.showRowNumbers == true) - } - - @Test("showRowNumbers round-trips through Codable") - func showRowNumbersCodableRoundTrip() throws { - var settings = DataGridSettings.default - settings.showRowNumbers = false - - let data = try JSONEncoder().encode(settings) - let decoded = try JSONDecoder().decode(DataGridSettings.self, from: data) - #expect(decoded.showRowNumbers == false) - } - - @Test("decoding without showRowNumbers key defaults to true") - func showRowNumbersBackwardsCompatibility() throws { - let oldJson = """ - { - "rowHeight": 24, - "dateFormat": "yyyy-MM-dd HH:mm:ss", - "nullDisplay": "NULL", - "defaultPageSize": 1000, - "showAlternateRows": true - } - """ - let data = oldJson.data(using: .utf8)! - let decoded = try JSONDecoder().decode(DataGridSettings.self, from: data) - #expect(decoded.showRowNumbers == true) - } - - // MARK: - Font Settings - - @Test("default font is systemMono at size 13") - func defaultFont() { - let settings = DataGridSettings.default - #expect(settings.fontFamily == .systemMono) - #expect(settings.fontSize == 13) - } - - @Test("font settings round-trip through Codable") - func fontCodableRoundTrip() throws { - var settings = DataGridSettings.default - settings.fontFamily = .menlo - settings.fontSize = 15 - - let data = try JSONEncoder().encode(settings) - let decoded = try JSONDecoder().decode(DataGridSettings.self, from: data) - #expect(decoded.fontFamily == .menlo) - #expect(decoded.fontSize == 15) - } - - @Test("decoding without font keys defaults to systemMono 13") - func fontBackwardsCompatibility() throws { - let oldJson = """ - { - "rowHeight": 24, - "dateFormat": "yyyy-MM-dd HH:mm:ss", - "nullDisplay": "NULL", - "defaultPageSize": 1000, - "showAlternateRows": true - } - """ - let data = oldJson.data(using: .utf8)! - let decoded = try JSONDecoder().decode(DataGridSettings.self, from: data) - #expect(decoded.fontFamily == .systemMono) - #expect(decoded.fontSize == 13) - } - - @Test("clampedFontSize clamps to 10-18 range", - arguments: [ - (input: 5, expected: 10), - (input: 10, expected: 10), - (input: 13, expected: 13), - (input: 18, expected: 18), - (input: 25, expected: 18), - ]) - func clampedFontSize(input: Int, expected: Int) { - var settings = DataGridSettings.default - settings.fontSize = input - #expect(settings.clampedFontSize == expected) - } -} - -@Suite("DataGridFontCache") -struct DataGridFontCacheTests { - @MainActor - @Test("reloadFromSettings produces valid font variants") - func reloadProducesValidFonts() { - let settings = DataGridSettings(fontFamily: .systemMono, fontSize: 13) - DataGridFontCache.reloadFromSettings(settings) - - #expect(DataGridFontCache.regular.pointSize > 0) - #expect(DataGridFontCache.italic.pointSize > 0) - #expect(DataGridFontCache.medium.pointSize > 0) - #expect(DataGridFontCache.rowNumber.pointSize > 0) - #expect(DataGridFontCache.monoCharWidth > 0) - } - - @MainActor - @Test("fonts update when reloadFromSettings called with different settings") - func fontsUpdateOnReload() { - DataGridFontCache.reloadFromSettings(DataGridSettings(fontFamily: .systemMono, fontSize: 13)) - let initialSize = DataGridFontCache.regular.pointSize - let initialCharWidth = DataGridFontCache.monoCharWidth - - DataGridFontCache.reloadFromSettings(DataGridSettings(fontFamily: .systemMono, fontSize: 18)) - #expect(DataGridFontCache.regular.pointSize > initialSize) - #expect(DataGridFontCache.monoCharWidth >= initialCharWidth) - } - - @MainActor - @Test("different font families produce different fonts") - func differentFamilies() { - DataGridFontCache.reloadFromSettings(DataGridSettings(fontFamily: .systemMono, fontSize: 13)) - let systemMonoName = DataGridFontCache.regular.fontName - - DataGridFontCache.reloadFromSettings(DataGridSettings(fontFamily: .menlo, fontSize: 13)) - let menloName = DataGridFontCache.regular.fontName - - #expect(systemMonoName != menloName) - } -} diff --git a/TableProTests/Models/DatabaseTypeCassandraTests.swift b/TableProTests/Models/DatabaseTypeCassandraTests.swift index e9abb07e..3bba70ab 100644 --- a/TableProTests/Models/DatabaseTypeCassandraTests.swift +++ b/TableProTests/Models/DatabaseTypeCassandraTests.swift @@ -5,82 +5,82 @@ import Testing struct DatabaseTypeCassandraTests { @Test("Cassandra raw value is Cassandra") func cassandraRawValue() { - #expect(.cassandra.rawValue == "Cassandra") + #expect(DatabaseType.cassandra.rawValue == "Cassandra") } @Test("ScyllaDB raw value is ScyllaDB") func scylladbRawValue() { - #expect(.scylladb.rawValue == "ScyllaDB") + #expect(DatabaseType.scylladb.rawValue == "ScyllaDB") } @Test("Cassandra pluginTypeId is Cassandra") func cassandraPluginTypeId() { - #expect(.cassandra.pluginTypeId == "Cassandra") + #expect(DatabaseType.cassandra.pluginTypeId == "Cassandra") } @Test("ScyllaDB pluginTypeId is Cassandra") func scylladbPluginTypeId() { - #expect(.scylladb.pluginTypeId == "Cassandra") + #expect(DatabaseType.scylladb.pluginTypeId == "Cassandra") } @Test("Cassandra default port is 9042") func cassandraDefaultPort() { - #expect(.cassandra.defaultPort == 9_042) + #expect(DatabaseType.cassandra.defaultPort == 9_042) } @Test("ScyllaDB default port is 9042") func scylladbDefaultPort() { - #expect(.scylladb.defaultPort == 9_042) + #expect(DatabaseType.scylladb.defaultPort == 9_042) } @Test("Cassandra does not require authentication") func cassandraRequiresAuthentication() { - #expect(.cassandra.requiresAuthentication == false) + #expect(DatabaseType.cassandra.requiresAuthentication == false) } @Test("ScyllaDB does not require authentication") func scylladbRequiresAuthentication() { - #expect(.scylladb.requiresAuthentication == false) + #expect(DatabaseType.scylladb.requiresAuthentication == false) } @Test("Cassandra does not support foreign keys") func cassandraSupportsForeignKeys() { - #expect(.cassandra.supportsForeignKeys == false) + #expect(DatabaseType.cassandra.supportsForeignKeys == false) } @Test("ScyllaDB does not support foreign keys") func scylladbSupportsForeignKeys() { - #expect(.scylladb.supportsForeignKeys == false) + #expect(DatabaseType.scylladb.supportsForeignKeys == false) } @Test("Cassandra supports schema editing") func cassandraSupportsSchemaEditing() { - #expect(.cassandra.supportsSchemaEditing == true) + #expect(DatabaseType.cassandra.supportsSchemaEditing == true) } @Test("ScyllaDB supports schema editing") func scylladbSupportsSchemaEditing() { - #expect(.scylladb.supportsSchemaEditing == true) + #expect(DatabaseType.scylladb.supportsSchemaEditing == true) } @Test("Cassandra icon name is cassandra-icon") func cassandraIconName() { - #expect(.cassandra.iconName == "cassandra-icon") + #expect(DatabaseType.cassandra.iconName == "cassandra-icon") } @Test("ScyllaDB icon name is scylladb-icon") func scylladbIconName() { - #expect(.scylladb.iconName == "scylladb-icon") + #expect(DatabaseType.scylladb.iconName == "scylladb-icon") } @Test("Cassandra is a downloadable plugin") func cassandraIsDownloadablePlugin() { - #expect(.cassandra.isDownloadablePlugin == true) + #expect(DatabaseType.cassandra.isDownloadablePlugin == true) } @Test("ScyllaDB is a downloadable plugin") func scylladbIsDownloadablePlugin() { - #expect(.scylladb.isDownloadablePlugin == true) + #expect(DatabaseType.scylladb.isDownloadablePlugin == true) } @Test("Cassandra included in allCases") diff --git a/TableProTests/Models/DatabaseTypeMSSQLTests.swift b/TableProTests/Models/DatabaseTypeMSSQLTests.swift index 7692f204..65b1d63b 100644 --- a/TableProTests/Models/DatabaseTypeMSSQLTests.swift +++ b/TableProTests/Models/DatabaseTypeMSSQLTests.swift @@ -15,32 +15,32 @@ struct DatabaseTypeMSSQLTests { @Test("defaultPort is 1433") func defaultPort() { - #expect(.mssql.defaultPort == 1_433) + #expect(DatabaseType.mssql.defaultPort == 1_433) } @Test("rawValue is SQL Server") func rawValue() { - #expect(.mssql.rawValue == "SQL Server") + #expect(DatabaseType.mssql.rawValue == "SQL Server") } @Test("requiresAuthentication is true") func requiresAuthentication() { - #expect(.mssql.requiresAuthentication == true) + #expect(DatabaseType.mssql.requiresAuthentication == true) } @Test("supportsForeignKeys is true") func supportsForeignKeys() { - #expect(.mssql.supportsForeignKeys == true) + #expect(DatabaseType.mssql.supportsForeignKeys == true) } @Test("supportsSchemaEditing is true") func supportsSchemaEditing() { - #expect(.mssql.supportsSchemaEditing == true) + #expect(DatabaseType.mssql.supportsSchemaEditing == true) } @Test("iconName is mssql-icon") func iconName() { - #expect(.mssql.iconName == "mssql-icon") + #expect(DatabaseType.mssql.iconName == "mssql-icon") } // MARK: - allKnownTypes Tests diff --git a/TableProTests/Models/DatabaseTypeRedisTests.swift b/TableProTests/Models/DatabaseTypeRedisTests.swift index d6e83488..5ace3b5b 100644 --- a/TableProTests/Models/DatabaseTypeRedisTests.swift +++ b/TableProTests/Models/DatabaseTypeRedisTests.swift @@ -5,37 +5,37 @@ import Testing struct DatabaseTypeRedisTests { @Test("Default port is 6379") func defaultPort() { - #expect(.redis.defaultPort == 6_379) + #expect(DatabaseType.redis.defaultPort == 6_379) } @Test("Icon name is redis-icon") func iconName() { - #expect(.redis.iconName == "redis-icon") + #expect(DatabaseType.redis.iconName == "redis-icon") } @Test("Does not require authentication") func requiresAuthentication() { - #expect(.redis.requiresAuthentication == false) + #expect(DatabaseType.redis.requiresAuthentication == false) } @Test("Does not support foreign keys") func supportsForeignKeys() { - #expect(.redis.supportsForeignKeys == false) + #expect(DatabaseType.redis.supportsForeignKeys == false) } @Test("Does not support schema editing") func supportsSchemaEditing() { - #expect(.redis.supportsSchemaEditing == false) + #expect(DatabaseType.redis.supportsSchemaEditing == false) } @Test("Raw value is Redis") func rawValue() { - #expect(.redis.rawValue == "Redis") + #expect(DatabaseType.redis.rawValue == "Redis") } @Test("Theme color is derived from plugin brand color") @MainActor func themeColor() { - #expect(.redis.themeColor == PluginManager.shared.brandColor(for: .redis)) + #expect(DatabaseType.redis.themeColor == PluginManager.shared.brandColor(for: .redis)) } @Test("Included in allKnownTypes") diff --git a/TableProTests/Models/SQLFileDeduplicationTests.swift b/TableProTests/Models/SQLFileDeduplicationTests.swift new file mode 100644 index 00000000..3890bc8a --- /dev/null +++ b/TableProTests/Models/SQLFileDeduplicationTests.swift @@ -0,0 +1,264 @@ +// +// SQLFileDeduplicationTests.swift +// TableProTests +// +// Tests for SQL file deduplication when opening .sql files in TablePro. +// Validates sourceFileURL tracking on QueryTab, EditorTabPayload, and PersistedTab, +// and deduplication logic in QueryTabManager. +// + +import AppKit +import Foundation +@testable import TablePro +import Testing + +// MARK: - QueryTab sourceFileURL Property Tests + +@Suite("QueryTab sourceFileURL") +struct QueryTabSourceFileURLTests { + @Test("QueryTab stores sourceFileURL when set") + func storesSourceFileURL() { + var tab = QueryTab(title: "Test", tabType: .query) + let url = URL(fileURLWithPath: "/tmp/test.sql") + tab.sourceFileURL = url + + #expect(tab.sourceFileURL == url) + } + + @Test("QueryTab sourceFileURL defaults to nil") + func defaultsToNil() { + let tab = QueryTab(title: "Test", tabType: .query) + + #expect(tab.sourceFileURL == nil) + } +} + +// MARK: - QueryTabManager Deduplication Tests + +@Suite("QueryTabManager SQL file deduplication") +struct QueryTabManagerDeduplicationTests { + @Test("addTab with sourceFileURL creates new tab when no duplicate exists") + @MainActor + func createsNewTabWithSourceFileURL() { + let tabManager = QueryTabManager() + let url = URL(fileURLWithPath: "/tmp/test.sql") + + tabManager.addTab(initialQuery: "SELECT 1", sourceFileURL: url) + + #expect(tabManager.tabs.count == 1) + #expect(tabManager.tabs.first?.sourceFileURL == url) + } + + @Test("addTab with same sourceFileURL selects existing tab instead of creating duplicate") + @MainActor + func deduplicatesSameSourceFileURL() { + let tabManager = QueryTabManager() + let url = URL(fileURLWithPath: "/tmp/test.sql") + + tabManager.addTab(initialQuery: "SELECT 1", sourceFileURL: url) + tabManager.addTab(initialQuery: "SELECT 2", sourceFileURL: url) + + #expect(tabManager.tabs.count == 1) + #expect(tabManager.selectedTabId == tabManager.tabs.first?.id) + } + + @Test("addTab with different sourceFileURL creates separate tabs") + @MainActor + func createsSeparateTabsForDifferentFiles() { + let tabManager = QueryTabManager() + let urlA = URL(fileURLWithPath: "/tmp/a.sql") + let urlB = URL(fileURLWithPath: "/tmp/b.sql") + + tabManager.addTab(initialQuery: "SELECT 1", sourceFileURL: urlA) + tabManager.addTab(initialQuery: "SELECT 2", sourceFileURL: urlB) + + #expect(tabManager.tabs.count == 2) + } + + @Test("addTab without sourceFileURL always creates new tab") + @MainActor + func noDedupWhenSourceFileURLIsNil() { + let tabManager = QueryTabManager() + + tabManager.addTab(initialQuery: "SELECT 1") + tabManager.addTab(initialQuery: "SELECT 2") + + #expect(tabManager.tabs.count == 2) + } + + @Test("addTab with sourceFileURL updates query content of existing tab") + @MainActor + func updatesQueryContentOnDuplicate() { + let tabManager = QueryTabManager() + let url = URL(fileURLWithPath: "/tmp/test.sql") + + tabManager.addTab(initialQuery: "SELECT 1", sourceFileURL: url) + tabManager.addTab(initialQuery: "SELECT 2", sourceFileURL: url) + + #expect(tabManager.tabs.count == 1) + #expect(tabManager.tabs.first?.query == "SELECT 2") + } +} + +// MARK: - EditorTabPayload sourceFileURL Tests + +@Suite("EditorTabPayload sourceFileURL") +struct EditorTabPayloadSourceFileURLTests { + @Test("EditorTabPayload carries sourceFileURL") + func carriesSourceFileURL() { + let url = URL(fileURLWithPath: "/tmp/test.sql") + let payload = EditorTabPayload( + connectionId: UUID(), + tabType: .query, + initialQuery: "SELECT 1", + sourceFileURL: url + ) + + #expect(payload.sourceFileURL == url) + } + + @Test("EditorTabPayload isConnectionOnly is true even with sourceFileURL") + func isConnectionOnlyUnaffectedBySourceFileURL() { + let url = URL(fileURLWithPath: "/tmp/test.sql") + let payload = EditorTabPayload( + connectionId: UUID(), + tabType: .query, + sourceFileURL: url + ) + + #expect(payload.isConnectionOnly == true) + } +} + +// MARK: - SessionStateFactory sourceFileURL Propagation Tests + +@Suite("SessionStateFactory sourceFileURL propagation") +struct SessionStateFactorySourceFileURLTests { + @Test("SessionStateFactory propagates sourceFileURL to tab") + @MainActor + func propagatesSourceFileURL() { + let conn = TestFixtures.makeConnection() + let url = URL(fileURLWithPath: "/tmp/test.sql") + let payload = EditorTabPayload( + connectionId: conn.id, + tabType: .query, + initialQuery: "SELECT 1", + sourceFileURL: url + ) + + let state = SessionStateFactory.create(connection: conn, payload: payload) + + #expect(state.tabManager.tabs.count == 1) + #expect(state.tabManager.tabs.first?.sourceFileURL == url) + } +} + +// MARK: - PersistedTab sourceFileURL Round-Trip Tests + +@Suite("PersistedTab sourceFileURL persistence") +struct PersistedTabSourceFileURLTests { + @Test("PersistedTab preserves sourceFileURL through encode/decode") + func roundTripsSourceFileURL() throws { + let url = URL(fileURLWithPath: "/tmp/test.sql") + let original = PersistedTab( + id: UUID(), + title: "Test", + query: "SELECT 1", + tabType: .query, + tableName: nil, + sourceFileURL: url + ) + + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(PersistedTab.self, from: data) + + #expect(decoded.sourceFileURL == url) + } + + @Test("PersistedTab without sourceFileURL decodes as nil") + func decodesNilSourceFileURL() throws { + let original = PersistedTab( + id: UUID(), + title: "Test", + query: "SELECT 1", + tabType: .query, + tableName: nil + ) + + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(PersistedTab.self, from: data) + + #expect(decoded.sourceFileURL == nil) + } +} + +// MARK: - WindowLifecycleMonitor Source File Tracking Tests + +@Suite("WindowLifecycleMonitor source file tracking") +@MainActor +struct WindowLifecycleMonitorSourceFileTests { + @Test("window(forSourceFile:) returns nil for unregistered URL") + func unregisteredURLReturnsNil() { + let url = URL(fileURLWithPath: "/tmp/unknown.sql") + #expect(WindowLifecycleMonitor.shared.window(forSourceFile: url) == nil) + } + + @Test("registerSourceFile and window(forSourceFile:) round-trip when window is alive") + func registerAndFindSourceFile() { + let url = URL(fileURLWithPath: "/tmp/registered.sql") + let windowId = UUID() + let window = NSWindow() + + WindowLifecycleMonitor.shared.register( + window: window, + connectionId: UUID(), + windowId: windowId + ) + WindowLifecycleMonitor.shared.registerSourceFile(url, windowId: windowId) + + #expect(WindowLifecycleMonitor.shared.window(forSourceFile: url) === window) + + WindowLifecycleMonitor.shared.unregisterSourceFile(url) + WindowLifecycleMonitor.shared.unregisterWindow(for: windowId) + } + + @Test("unregisterSourceFiles(for:) removes all files for a window") + func unregisterAllFilesForWindow() { + let url1 = URL(fileURLWithPath: "/tmp/file1.sql") + let url2 = URL(fileURLWithPath: "/tmp/file2.sql") + let windowId = UUID() + let window = NSWindow() + + WindowLifecycleMonitor.shared.register( + window: window, + connectionId: UUID(), + windowId: windowId + ) + WindowLifecycleMonitor.shared.registerSourceFile(url1, windowId: windowId) + WindowLifecycleMonitor.shared.registerSourceFile(url2, windowId: windowId) + + WindowLifecycleMonitor.shared.unregisterSourceFiles(for: windowId) + + #expect(WindowLifecycleMonitor.shared.window(forSourceFile: url1) == nil) + #expect(WindowLifecycleMonitor.shared.window(forSourceFile: url2) == nil) + + WindowLifecycleMonitor.shared.unregisterWindow(for: windowId) + } + + @Test("window(forSourceFile:) returns nil after window is unregistered") + func returnsNilAfterWindowUnregistered() { + let url = URL(fileURLWithPath: "/tmp/closed.sql") + let windowId = UUID() + let window = NSWindow() + + WindowLifecycleMonitor.shared.register( + window: window, + connectionId: UUID(), + windowId: windowId + ) + WindowLifecycleMonitor.shared.registerSourceFile(url, windowId: windowId) + WindowLifecycleMonitor.shared.unregisterWindow(for: windowId) + + #expect(WindowLifecycleMonitor.shared.window(forSourceFile: url) == nil) + } +} diff --git a/TableProTests/Plugins/DynamoDBStatementGeneratorTests.swift b/TableProTests/Plugins/DynamoDBStatementGeneratorTests.swift deleted file mode 100644 index f35d7c97..00000000 --- a/TableProTests/Plugins/DynamoDBStatementGeneratorTests.swift +++ /dev/null @@ -1,686 +0,0 @@ -// -// DynamoDBStatementGeneratorTests.swift -// TableProTests -// -// Tests for DynamoDBStatementGenerator (compiled via symlink from DynamoDBDriverPlugin). -// - -import Foundation -import Testing -import TableProPluginKit - -@Suite("DynamoDB Statement Generator") -struct DynamoDBStatementGeneratorTests { - - private func makeGenerator( - table: String = "TestTable", - columns: [String] = ["id", "name", "age"], - columnTypeNames: [String] = ["S", "S", "N"], - keySchema: [(name: String, keyType: String)] = [("id", "HASH")] - ) -> DynamoDBStatementGenerator { - DynamoDBStatementGenerator( - tableName: table, - columns: columns, - columnTypeNames: columnTypeNames, - keySchema: keySchema - ) - } - - private func insertChange( - rowIndex: Int = 0 - ) -> PluginRowChange { - PluginRowChange( - rowIndex: rowIndex, - type: .insert, - cellChanges: [], - originalRow: nil - ) - } - - // MARK: - INSERT - - @Test("Basic insert with string columns") - func basicInsert() throws { - let gen = makeGenerator( - columns: ["id", "name"], - columnTypeNames: ["S", "S"], - keySchema: [("id", "HASH")] - ) - - let results = try gen.generateStatements( - from: [insertChange()], - insertedRowData: [0: ["pk1", "Alice"]], - deletedRowIndices: [], - insertedRowIndices: [0] - ) - - #expect(results.count == 1) - #expect(results[0].statement == "INSERT INTO \"TestTable\" VALUE { 'id': 'pk1', 'name': 'Alice' }") - } - - @Test("Insert with number type produces unquoted value") - func insertWithNumber() throws { - let gen = makeGenerator() - - let results = try gen.generateStatements( - from: [insertChange()], - insertedRowData: [0: ["pk1", "Alice", "30"]], - deletedRowIndices: [], - insertedRowIndices: [0] - ) - - #expect(results.count == 1) - #expect(results[0].statement.contains("'age': 30")) - } - - @Test("Insert with boolean type") - func insertWithBoolean() throws { - let gen = makeGenerator( - columns: ["id", "active"], - columnTypeNames: ["S", "BOOL"], - keySchema: [("id", "HASH")] - ) - - let results = try gen.generateStatements( - from: [insertChange()], - insertedRowData: [0: ["pk1", "true"]], - deletedRowIndices: [], - insertedRowIndices: [0] - ) - - #expect(results.count == 1) - #expect(results[0].statement.contains("'active': true")) - } - - @Test("Insert with NULL type and non-null value treats as string") - func insertWithNull() throws { - let gen = makeGenerator( - columns: ["id", "data"], - columnTypeNames: ["S", "NULL"], - keySchema: [("id", "HASH")] - ) - - let results = try gen.generateStatements( - from: [insertChange()], - insertedRowData: [0: ["pk1", "anything"]], - deletedRowIndices: [], - insertedRowIndices: [0] - ) - - #expect(results.count == 1) - #expect(results[0].statement.contains("'data': 'anything'")) - } - - @Test("Insert with mixed types") - func insertWithMixedTypes() throws { - let gen = makeGenerator( - columns: ["id", "name", "score", "active"], - columnTypeNames: ["S", "S", "N", "BOOL"], - keySchema: [("id", "HASH")] - ) - - let results = try gen.generateStatements( - from: [insertChange()], - insertedRowData: [0: ["pk1", "Bob", "99", "false"]], - deletedRowIndices: [], - insertedRowIndices: [0] - ) - - #expect(results.count == 1) - let stmt = results[0].statement - #expect(stmt.contains("'id': 'pk1'")) - #expect(stmt.contains("'name': 'Bob'")) - #expect(stmt.contains("'score': 99")) - #expect(stmt.contains("'active': false")) - } - - @Test("Insert missing key column produces empty result") - func insertMissingKey() throws { - let gen = makeGenerator() - - let results = try gen.generateStatements( - from: [insertChange()], - insertedRowData: [0: [nil, "Alice", "30"]], - deletedRowIndices: [], - insertedRowIndices: [0] - ) - - #expect(results.isEmpty) - } - - @Test("Insert with single quotes in value escapes them") - func insertWithSingleQuotes() throws { - let gen = makeGenerator( - columns: ["id", "name"], - columnTypeNames: ["S", "S"], - keySchema: [("id", "HASH")] - ) - - let results = try gen.generateStatements( - from: [insertChange()], - insertedRowData: [0: ["pk1", "O'Brien"]], - deletedRowIndices: [], - insertedRowIndices: [0] - ) - - #expect(results.count == 1) - #expect(results[0].statement.contains("'O''Brien'")) - } - - @Test("Insert with complex type passes value through as-is") - func insertWithComplexType() throws { - let gen = makeGenerator( - columns: ["id", "tags"], - columnTypeNames: ["S", "L"], - keySchema: [("id", "HASH")] - ) - - let results = try gen.generateStatements( - from: [insertChange()], - insertedRowData: [0: ["pk1", "[\"a\",\"b\"]"]], - deletedRowIndices: [], - insertedRowIndices: [0] - ) - - #expect(results.count == 1) - #expect(results[0].statement.contains("'tags': [\"a\",\"b\"]")) - } - - // MARK: - UPDATE - - @Test("Basic update of non-key column") - func basicUpdate() throws { - let gen = makeGenerator() - - let change = PluginRowChange( - rowIndex: 0, - type: .update, - cellChanges: [ - PluginCellChange(columnName: "name", oldValue: "Alice", newValue: "Bob") - ], - originalRow: ["pk1", "Alice", "30"] - ) - - let results = try gen.generateStatements( - from: [change], - insertedRowData: [:], - deletedRowIndices: [], - insertedRowIndices: [] - ) - - #expect(results.count == 1) - #expect(results[0].statement == "UPDATE \"TestTable\" SET \"name\" = 'Bob' WHERE \"id\" = 'pk1'") - } - - @Test("Update with composite key produces AND in WHERE") - func updateCompositeKey() throws { - let gen = makeGenerator( - columns: ["pk", "sk", "val"], - columnTypeNames: ["S", "S", "S"], - keySchema: [("pk", "HASH"), ("sk", "RANGE")] - ) - - let change = PluginRowChange( - rowIndex: 0, - type: .update, - cellChanges: [ - PluginCellChange(columnName: "val", oldValue: "old", newValue: "new") - ], - originalRow: ["partKey", "sortKey", "old"] - ) - - let results = try gen.generateStatements( - from: [change], - insertedRowData: [:], - deletedRowIndices: [], - insertedRowIndices: [] - ) - - #expect(results.count == 1) - #expect(results[0].statement.contains("WHERE \"pk\" = 'partKey' AND \"sk\" = 'sortKey'")) - } - - @Test("Update key column only is skipped") - func updateKeyColumnOnly() throws { - let gen = makeGenerator() - - let change = PluginRowChange( - rowIndex: 0, - type: .update, - cellChanges: [ - PluginCellChange(columnName: "id", oldValue: "pk1", newValue: "pk2") - ], - originalRow: ["pk1", "Alice", "30"] - ) - - let results = try gen.generateStatements( - from: [change], - insertedRowData: [:], - deletedRowIndices: [], - insertedRowIndices: [] - ) - - #expect(results.isEmpty) - } - - @Test("Update with NULL new value") - func updateWithNull() throws { - let gen = makeGenerator() - - let change = PluginRowChange( - rowIndex: 0, - type: .update, - cellChanges: [ - PluginCellChange(columnName: "name", oldValue: "Alice", newValue: nil) - ], - originalRow: ["pk1", "Alice", "30"] - ) - - let results = try gen.generateStatements( - from: [change], - insertedRowData: [:], - deletedRowIndices: [], - insertedRowIndices: [] - ) - - #expect(results.count == 1) - #expect(results[0].statement.contains("SET \"name\" = NULL")) - } - - @Test("Update with number type value") - func updateWithNumber() throws { - let gen = makeGenerator() - - let change = PluginRowChange( - rowIndex: 0, - type: .update, - cellChanges: [ - PluginCellChange(columnName: "age", oldValue: "30", newValue: "31") - ], - originalRow: ["pk1", "Alice", "30"] - ) - - let results = try gen.generateStatements( - from: [change], - insertedRowData: [:], - deletedRowIndices: [], - insertedRowIndices: [] - ) - - #expect(results.count == 1) - #expect(results[0].statement.contains("SET \"age\" = 31")) - } - - // MARK: - DELETE - - @Test("Basic delete with key condition") - func basicDelete() throws { - let gen = makeGenerator() - - let change = PluginRowChange( - rowIndex: 0, - type: .delete, - cellChanges: [], - originalRow: ["pk1", "Alice", "30"] - ) - - let results = try gen.generateStatements( - from: [change], - insertedRowData: [:], - deletedRowIndices: [0], - insertedRowIndices: [] - ) - - #expect(results.count == 1) - #expect(results[0].statement == "DELETE FROM \"TestTable\" WHERE \"id\" = 'pk1'") - } - - @Test("Delete with composite key") - func deleteCompositeKey() throws { - let gen = makeGenerator( - columns: ["pk", "sk", "val"], - columnTypeNames: ["S", "S", "S"], - keySchema: [("pk", "HASH"), ("sk", "RANGE")] - ) - - let change = PluginRowChange( - rowIndex: 0, - type: .delete, - cellChanges: [], - originalRow: ["partKey", "sortKey", "value"] - ) - - let results = try gen.generateStatements( - from: [change], - insertedRowData: [:], - deletedRowIndices: [0], - insertedRowIndices: [] - ) - - #expect(results.count == 1) - #expect(results[0].statement.contains("WHERE \"pk\" = 'partKey' AND \"sk\" = 'sortKey'")) - } - - @Test("Delete with missing originalRow returns nil") - func deleteMissingOriginalRow() throws { - let gen = makeGenerator() - - let change = PluginRowChange( - rowIndex: 0, - type: .delete, - cellChanges: [], - originalRow: nil - ) - - let results = try gen.generateStatements( - from: [change], - insertedRowData: [:], - deletedRowIndices: [0], - insertedRowIndices: [] - ) - - #expect(results.isEmpty) - } - - // MARK: - formatValue Validation - - @Test("Valid integer number does not throw") - func validIntegerNumber() throws { - let gen = makeGenerator( - columns: ["id", "count"], - columnTypeNames: ["S", "N"], - keySchema: [("id", "HASH")] - ) - - let results = try gen.generateStatements( - from: [insertChange()], - insertedRowData: [0: ["pk1", "42"]], - deletedRowIndices: [], - insertedRowIndices: [0] - ) - - #expect(results.count == 1) - #expect(results[0].statement.contains("'count': 42")) - } - - @Test("Valid float number does not throw") - func validFloatNumber() throws { - let gen = makeGenerator( - columns: ["id", "price"], - columnTypeNames: ["S", "N"], - keySchema: [("id", "HASH")] - ) - - let results = try gen.generateStatements( - from: [insertChange()], - insertedRowData: [0: ["pk1", "3.14"]], - deletedRowIndices: [], - insertedRowIndices: [0] - ) - - #expect(results.count == 1) - #expect(results[0].statement.contains("'price': 3.14")) - } - - @Test("Invalid number throws invalidNumber error") - func invalidNumber() { - let gen = makeGenerator( - columns: ["id", "count"], - columnTypeNames: ["S", "N"], - keySchema: [("id", "HASH")] - ) - - #expect(throws: DynamoDBStatementError.self) { - _ = try gen.generateStatements( - from: [insertChange()], - insertedRowData: [0: ["pk1", "abc"]], - deletedRowIndices: [], - insertedRowIndices: [0] - ) - } - } - - @Test("Valid boolean values do not throw", arguments: ["true", "false", "1", "0"]) - func validBoolean(value: String) throws { - let gen = makeGenerator( - columns: ["id", "flag"], - columnTypeNames: ["S", "BOOL"], - keySchema: [("id", "HASH")] - ) - - let results = try gen.generateStatements( - from: [insertChange()], - insertedRowData: [0: ["pk1", value]], - deletedRowIndices: [], - insertedRowIndices: [0] - ) - - #expect(results.count == 1) - } - - @Test("Invalid boolean throws invalidBoolean error") - func invalidBoolean() { - let gen = makeGenerator( - columns: ["id", "flag"], - columnTypeNames: ["S", "BOOL"], - keySchema: [("id", "HASH")] - ) - - #expect(throws: DynamoDBStatementError.self) { - _ = try gen.generateStatements( - from: [insertChange()], - insertedRowData: [0: ["pk1", "yes"]], - deletedRowIndices: [], - insertedRowIndices: [0] - ) - } - } - - // MARK: - String Set (SS) - - @Test("String set formats as PartiQL set literal") - func stringSetFormat() throws { - let gen = makeGenerator( - columns: ["id", "tags"], - columnTypeNames: ["S", "SS"], - keySchema: [("id", "HASH")] - ) - - let results = try gen.generateStatements( - from: [insertChange()], - insertedRowData: [0: ["pk1", "[\"a\",\"b\",\"c\"]"]], - deletedRowIndices: [], - insertedRowIndices: [0] - ) - - #expect(results.count == 1) - #expect(results[0].statement.contains("'tags': <<'a', 'b', 'c'>>")) - } - - @Test("String set escapes single quotes in elements") - func stringSetEscapesSingleQuotes() throws { - let gen = makeGenerator( - columns: ["id", "tags"], - columnTypeNames: ["S", "SS"], - keySchema: [("id", "HASH")] - ) - - let results = try gen.generateStatements( - from: [insertChange()], - insertedRowData: [0: ["pk1", "[\"it's\",\"fine\"]"]], - deletedRowIndices: [], - insertedRowIndices: [0] - ) - - #expect(results.count == 1) - #expect(results[0].statement.contains("<<'it''s', 'fine'>>")) - } - - // MARK: - Number Set (NS) - - @Test("Number set formats as PartiQL set literal") - func numberSetFormat() throws { - let gen = makeGenerator( - columns: ["id", "scores"], - columnTypeNames: ["S", "NS"], - keySchema: [("id", "HASH")] - ) - - let results = try gen.generateStatements( - from: [insertChange()], - insertedRowData: [0: ["pk1", "[1, 2, 3]"]], - deletedRowIndices: [], - insertedRowIndices: [0] - ) - - #expect(results.count == 1) - #expect(results[0].statement.contains("'scores': <<1, 2, 3>>")) - } - - @Test("Number set with invalid element throws invalidNumber") - func numberSetInvalidElement() { - let gen = makeGenerator( - columns: ["id", "scores"], - columnTypeNames: ["S", "NS"], - keySchema: [("id", "HASH")] - ) - - #expect(throws: DynamoDBStatementError.self) { - _ = try gen.generateStatements( - from: [insertChange()], - insertedRowData: [0: ["pk1", "[1, \"abc\", 3]"]], - deletedRowIndices: [], - insertedRowIndices: [0] - ) - } - } - - // MARK: - Binary (B, BS) - - @Test("Binary type throws unsupportedBinaryType") - func binaryTypeThrows() { - let gen = makeGenerator( - columns: ["id", "data"], - columnTypeNames: ["S", "B"], - keySchema: [("id", "HASH")] - ) - - #expect(throws: DynamoDBStatementError.self) { - _ = try gen.generateStatements( - from: [insertChange()], - insertedRowData: [0: ["pk1", "dGVzdA=="]], - deletedRowIndices: [], - insertedRowIndices: [0] - ) - } - } - - @Test("Binary set type throws unsupportedBinaryType") - func binarySetTypeThrows() { - let gen = makeGenerator( - columns: ["id", "images"], - columnTypeNames: ["S", "BS"], - keySchema: [("id", "HASH")] - ) - - #expect(throws: DynamoDBStatementError.self) { - _ = try gen.generateStatements( - from: [insertChange()], - insertedRowData: [0: ["pk1", "[\"dGVzdA==\"]"]], - deletedRowIndices: [], - insertedRowIndices: [0] - ) - } - } - - // MARK: - NULL to value edit - - @Test("NULL type with actual value falls through to string") - func nullTypeWithRealValue() throws { - let gen = makeGenerator( - columns: ["id", "data"], - columnTypeNames: ["S", "NULL"], - keySchema: [("id", "HASH")] - ) - - let change = PluginRowChange( - rowIndex: 0, - type: .update, - cellChanges: [ - PluginCellChange(columnName: "data", oldValue: "NULL", newValue: "hello world") - ], - originalRow: ["pk1", "NULL"] - ) - - let results = try gen.generateStatements( - from: [change], - insertedRowData: [:], - deletedRowIndices: [], - insertedRowIndices: [] - ) - - #expect(results.count == 1) - #expect(results[0].statement.contains("SET \"data\" = 'hello world'")) - } - - @Test("NULL type with empty value returns NULL") - func nullTypeWithEmptyValue() throws { - let gen = makeGenerator( - columns: ["id", "data"], - columnTypeNames: ["S", "NULL"], - keySchema: [("id", "HASH")] - ) - - let results = try gen.generateStatements( - from: [insertChange()], - insertedRowData: [0: ["pk1", ""]], - deletedRowIndices: [], - insertedRowIndices: [0] - ) - - // Empty value for NULL type should still produce NULL - #expect(results.count == 1) - #expect(results[0].statement.contains("'data': NULL")) - } - - @Test("NULL type with 'null' string returns NULL") - func nullTypeWithNullString() throws { - let gen = makeGenerator( - columns: ["id", "data"], - columnTypeNames: ["S", "NULL"], - keySchema: [("id", "HASH")] - ) - - let results = try gen.generateStatements( - from: [insertChange()], - insertedRowData: [0: ["pk1", "null"]], - deletedRowIndices: [], - insertedRowIndices: [0] - ) - - #expect(results.count == 1) - #expect(results[0].statement.contains("'data': NULL")) - } - - // MARK: - Identifier Escaping - - @Test("Table name with double quotes is escaped") - func tableNameEscaping() throws { - let gen = makeGenerator( - table: "My\"Table", - columns: ["id"], - columnTypeNames: ["S"], - keySchema: [("id", "HASH")] - ) - - let results = try gen.generateStatements( - from: [insertChange()], - insertedRowData: [0: ["pk1"]], - deletedRowIndices: [], - insertedRowIndices: [0] - ) - - #expect(results.count == 1) - #expect(results[0].statement.contains("\"My\"\"Table\"")) - } -}