diff --git a/CHANGELOG.md b/CHANGELOG.md index 766afd9ee..77ddab9c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fix AI chat hanging the app during streaming, schema fetch, and conversation loading (#735) - SSH Agent auth: fall back to key file from `~/.ssh/config` or default paths when agent has no loaded identities (#729) - SSH-tunneled connections failing to reconnect after idle/sleep — health monitor now rebuilds the tunnel, OS-level TCP keepalive detects dead NAT mappings, and wake-from-sleep triggers immediate validation (#736) +- Composite primary key tables: editing or deleting a row affects all rows sharing the first PK value instead of just the target row +- Structure view saves bypass safe mode on read-only connections ## [0.31.4] - 2026-04-14 diff --git a/Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver.swift b/Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver.swift index 36ba43d23..3e704b1df 100644 --- a/Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver.swift +++ b/Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver.swift @@ -214,7 +214,8 @@ final class CloudflareD1PluginDriver: PluginDatabaseDriver, @unchecked Sendable } let isNullable = row[3] == "0" - let isPrimaryKey = row[5] == "1" + // PRAGMA table_info pk column: 0 = not PK, 1+ = position in composite PK + let isPrimaryKey = row[5] != nil && row[5] != "0" let defaultValue = row[4] return PluginColumnInfo( @@ -248,7 +249,8 @@ final class CloudflareD1PluginDriver: PluginDatabaseDriver, @unchecked Sendable let isNullable = row[4] == "0" let defaultValue = row[5] - let isPrimaryKey = row[6] == "1" + // PRAGMA table_info pk column: 0 = not PK, 1+ = position in composite PK + let isPrimaryKey = row[6] != nil && row[6] != "0" let column = PluginColumnInfo( name: columnName, diff --git a/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift b/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift index 34b46c050..d04bf8bdc 100644 --- a/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift +++ b/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift @@ -520,7 +520,8 @@ final class SQLitePluginDriver: PluginDatabaseDriver, @unchecked Sendable { } let isNullable = row[3] == "0" - let isPrimaryKey = row[5] == "1" + // PRAGMA table_info pk column: 0 = not PK, 1+ = position in composite PK + let isPrimaryKey = row[5] != nil && row[5] != "0" let defaultValue = row[4] return PluginColumnInfo( @@ -554,7 +555,8 @@ final class SQLitePluginDriver: PluginDatabaseDriver, @unchecked Sendable { let isNullable = row[4] == "0" let defaultValue = row[5] - let isPrimaryKey = row[6] == "1" + // PRAGMA table_info pk column: 0 = not PK, 1+ = position in composite PK + let isPrimaryKey = row[6] != nil && row[6] != "0" let column = PluginColumnInfo( name: columnName, diff --git a/TablePro/Core/ChangeTracking/DataChangeManager.swift b/TablePro/Core/ChangeTracking/DataChangeManager.swift index e139da512..ffde7d698 100644 --- a/TablePro/Core/ChangeTracking/DataChangeManager.swift +++ b/TablePro/Core/ChangeTracking/DataChangeManager.swift @@ -32,7 +32,9 @@ final class DataChangeManager { private(set) var changedRowIndices: Set = [] var tableName: String = "" - var primaryKeyColumn: String? + var primaryKeyColumns: [String] = [] + /// First PK column, for contexts that need a single column (paste, filters) + var primaryKeyColumn: String? { primaryKeyColumns.first } var databaseType: DatabaseType = .mysql var pluginDriver: (any PluginDatabaseDriver)? @@ -141,13 +143,13 @@ final class DataChangeManager { func configureForTable( tableName: String, columns: [String], - primaryKeyColumn: String?, + primaryKeyColumns: [String], databaseType: DatabaseType = .mysql, triggerReload: Bool = true ) { self.tableName = tableName self.columns = columns - self.primaryKeyColumn = primaryKeyColumn + self.primaryKeyColumns = primaryKeyColumns self.databaseType = databaseType changeIndex.removeAll() @@ -847,7 +849,7 @@ final class DataChangeManager { let generator = SQLStatementGenerator( tableName: tableName, columns: columns, - primaryKeyColumn: primaryKeyColumn, + primaryKeyColumns: primaryKeyColumns, databaseType: databaseType, dialect: PluginManager.shared.sqlDialect(for: databaseType), quoteIdentifier: pluginDriver?.quoteIdentifier @@ -909,6 +911,7 @@ final class DataChangeManager { insertedRowIndices.removeAll() modifiedCells.removeAll() insertedRowData.removeAll() + changedRowIndices.removeAll() hasChanges = false reloadVersion += 1 } @@ -922,7 +925,7 @@ final class DataChangeManager { state.insertedRowIndices = insertedRowIndices state.modifiedCells = modifiedCells state.insertedRowData = insertedRowData - state.primaryKeyColumn = primaryKeyColumn + state.primaryKeyColumns = primaryKeyColumns state.columns = columns return state } @@ -930,7 +933,7 @@ final class DataChangeManager { func restoreState(from state: TabPendingChanges, tableName: String, databaseType: DatabaseType) { self.tableName = tableName self.columns = state.columns - self.primaryKeyColumn = state.primaryKeyColumn + self.primaryKeyColumns = state.primaryKeyColumns self.databaseType = databaseType self.changes = state.changes self.deletedRowIndices = state.deletedRowIndices diff --git a/TablePro/Core/ChangeTracking/SQLStatementGenerator.swift b/TablePro/Core/ChangeTracking/SQLStatementGenerator.swift index 774b48876..ebcb83508 100644 --- a/TablePro/Core/ChangeTracking/SQLStatementGenerator.swift +++ b/TablePro/Core/ChangeTracking/SQLStatementGenerator.swift @@ -22,7 +22,7 @@ struct SQLStatementGenerator { let tableName: String let columns: [String] - let primaryKeyColumn: String? + let primaryKeyColumns: [String] let databaseType: DatabaseType let parameterStyle: ParameterStyle private let quoteIdentifierFn: (String) -> String @@ -30,7 +30,7 @@ struct SQLStatementGenerator { init( tableName: String, columns: [String], - primaryKeyColumn: String?, + primaryKeyColumns: [String], databaseType: DatabaseType, parameterStyle: ParameterStyle? = nil, dialect: SQLDialectDescriptor? = nil, @@ -38,7 +38,7 @@ struct SQLStatementGenerator { ) { self.tableName = tableName self.columns = columns - self.primaryKeyColumn = primaryKeyColumn + self.primaryKeyColumns = primaryKeyColumns self.databaseType = databaseType self.parameterStyle = parameterStyle ?? Self.defaultParameterStyle(for: databaseType) self.quoteIdentifierFn = quoteIdentifier ?? quoteIdentifierFromDialect(dialect) @@ -250,27 +250,35 @@ struct SQLStatementGenerator { } }.joined(separator: ", ") - if let pkColumn = primaryKeyColumn, - let pkColumnIndex = columns.firstIndex(of: pkColumn) - { - var pkValue: Any? - if let originalRow = change.originalRow, pkColumnIndex < originalRow.count { - pkValue = originalRow[pkColumnIndex] - } else if let pkChange = change.cellChanges.first(where: { $0.columnName == pkColumn }) - { - pkValue = pkChange.oldValue - } + if !primaryKeyColumns.isEmpty { + var conditions: [String] = [] - guard pkValue != nil else { - Self.logger.warning( - "Skipping UPDATE for table '\(self.tableName)' - cannot determine primary key value for row" + for pkColumn in primaryKeyColumns { + guard let pkColumnIndex = columns.firstIndex(of: pkColumn) else { return nil } + + var pkValue: Any? + if let originalRow = change.originalRow, pkColumnIndex < originalRow.count { + pkValue = originalRow[pkColumnIndex] + } else if let pkChange = change.cellChanges.first(where: { $0.columnName == pkColumn }) { + pkValue = pkChange.oldValue + } + + guard pkValue != nil else { + Self.logger.warning( + "Skipping UPDATE for table '\(self.tableName)' - cannot determine value for PK column '\(pkColumn)'" + ) + return nil + } + + parameters.append(pkValue) + conditions.append( + "\(quoteIdentifierFn(pkColumn)) = \(placeholder(at: parameters.count - 1))" ) - return nil } - parameters.append(pkValue) - let whereClause = - "\(quoteIdentifierFn(pkColumn)) = \(placeholder(at: parameters.count - 1))" + guard !conditions.isEmpty else { return nil } + + let whereClause = conditions.joined(separator: " AND ") let sql = "UPDATE \(quoteIdentifierFn(tableName)) SET \(setClauses) WHERE \(whereClause)" return ParameterizedStatement(sql: sql, parameters: parameters) @@ -311,27 +319,35 @@ struct SQLStatementGenerator { private func generateBatchDeleteSQL(for changes: [RowChange]) -> ParameterizedStatement? { guard !changes.isEmpty else { return nil } - // If we have a primary key, use it for efficient deletion - if let pkColumn = primaryKeyColumn, - let pkIndex = columns.firstIndex(of: pkColumn) - { - // Build OR conditions for all rows using PK + // If we have primary key(s), use them for efficient deletion + if !primaryKeyColumns.isEmpty { + let pkIndices: [(column: String, index: Int)] = primaryKeyColumns.compactMap { col in + guard let idx = columns.firstIndex(of: col) else { return nil } + return (col, idx) + } + guard !pkIndices.isEmpty else { return nil } + var parameters: [Any?] = [] - let conditions = changes.compactMap { change -> String? in - guard let originalRow = change.originalRow, - pkIndex < originalRow.count - else { - return nil + let rowConditions = changes.compactMap { change -> String? in + guard let originalRow = change.originalRow else { return nil } + + var pkConditions: [String] = [] + for pk in pkIndices { + guard pk.index < originalRow.count else { return nil } + parameters.append(originalRow[pk.index]) + pkConditions.append( + "\(quoteIdentifierFn(pk.column)) = \(placeholder(at: parameters.count - 1))" + ) } - - parameters.append(originalRow[pkIndex]) - return - "\(quoteIdentifierFn(pkColumn)) = \(placeholder(at: parameters.count - 1))" + // Single PK: "id = $1", composite: "(order_id = $1 AND product_id = $2)" + return pkIndices.count > 1 + ? "(\(pkConditions.joined(separator: " AND ")))" + : pkConditions.joined() } - guard !conditions.isEmpty else { return nil } + guard !rowConditions.isEmpty else { return nil } - let whereClause = conditions.joined(separator: " OR ") + let whereClause = rowConditions.joined(separator: " OR ") let sql = "DELETE FROM \(quoteIdentifierFn(tableName)) WHERE \(whereClause)" return ParameterizedStatement(sql: sql, parameters: parameters) diff --git a/TablePro/Core/Services/Query/RowOperationsManager.swift b/TablePro/Core/Services/Query/RowOperationsManager.swift index 0512f9ed4..e3c2a324c 100644 --- a/TablePro/Core/Services/Query/RowOperationsManager.swift +++ b/TablePro/Core/Services/Query/RowOperationsManager.swift @@ -75,9 +75,10 @@ final class RowOperationsManager { var newValues = resultRows[sourceRowIndex] - if let pkColumn = changeManager.primaryKeyColumn, - let pkIndex = columns.firstIndex(of: pkColumn) { - newValues[pkIndex] = "__DEFAULT__" + for pkColumn in changeManager.primaryKeyColumns { + if let pkIndex = columns.firstIndex(of: pkColumn) { + newValues[pkIndex] = "__DEFAULT__" + } } let newRowIndex = resultRows.count @@ -311,7 +312,7 @@ final class RowOperationsManager { /// Paste rows from clipboard (TSV format) and insert into table /// - Parameters: /// - columns: Column names for the table - /// - primaryKeyColumn: Primary key column name (will be set to __DEFAULT__) + /// - primaryKeyColumns: Primary key column names (will be set to __DEFAULT__) /// - resultRows: Current rows (will be mutated) /// - clipboard: Clipboard provider (injectable for testing) /// - parser: Row data parser (injectable for testing) @@ -319,7 +320,7 @@ final class RowOperationsManager { @MainActor func pasteRowsFromClipboard( columns: [String], - primaryKeyColumn: String?, + primaryKeyColumns: [String], resultRows: inout [[String?]], clipboard: ClipboardProvider? = nil, parser: RowDataParser? = nil @@ -333,7 +334,7 @@ final class RowOperationsManager { // Create schema let schema = TableSchema( columns: columns, - primaryKeyColumn: primaryKeyColumn + primaryKeyColumns: primaryKeyColumns ) // Parse clipboard text (auto-detect CSV vs TSV) diff --git a/TablePro/Models/Database/TableSchema.swift b/TablePro/Models/Database/TableSchema.swift index 685937e3b..578a8df7d 100644 --- a/TablePro/Models/Database/TableSchema.swift +++ b/TablePro/Models/Database/TableSchema.swift @@ -12,20 +12,26 @@ struct TableSchema { /// Column names in order let columns: [String] - /// Primary key column name (if exists) - let primaryKeyColumn: String? + /// Primary key column names (empty if no PK). Supports composite keys. + let primaryKeyColumns: [String] + + /// First primary key column name, for UI contexts that need a single column + /// (e.g., default filter column, ORDER BY). + var primaryKeyColumn: String? { primaryKeyColumns.first } /// Number of columns var columnCount: Int { columns.count } - /// Get index of primary key column - var primaryKeyIndex: Int? { - guard let pkColumn = primaryKeyColumn else { return nil } - return columns.firstIndex(of: pkColumn) + /// Get indices of all primary key columns + var primaryKeyIndices: [Int] { + primaryKeyColumns.compactMap { columns.firstIndex(of: $0) } } + /// Get index of first primary key column + var primaryKeyIndex: Int? { primaryKeyIndices.first } + /// Check if a column name exists func hasColumn(_ name: String) -> Bool { columns.contains(name) diff --git a/TablePro/Models/Query/QueryTab.swift b/TablePro/Models/Query/QueryTab.swift index e27d8ffc3..3e56804b8 100644 --- a/TablePro/Models/Query/QueryTab.swift +++ b/TablePro/Models/Query/QueryTab.swift @@ -66,7 +66,9 @@ struct QueryTab: Identifiable, Equatable { // Editing support var tableName: String? - var primaryKeyColumn: String? // Detected PK from schema (set by Phase 2 metadata) + var primaryKeyColumns: [String] = [] // Detected PKs from schema (set by Phase 2 metadata) + /// First PK column, for UI contexts that need a single column (filters, ORDER BY) + var primaryKeyColumn: String? { primaryKeyColumns.first } var isEditable: Bool var isView: Bool // True for database views (read-only) var databaseName: String // Database this tab was opened in (for multi-database restore) @@ -158,7 +160,7 @@ struct QueryTab: Identifiable, Equatable { self.errorMessage = nil self.isExecuting = false self.tableName = tableName - self.primaryKeyColumn = nil + self.primaryKeyColumns = [] self.isEditable = tabType == .table self.isView = false self.databaseName = "" @@ -185,7 +187,7 @@ struct QueryTab: Identifiable, Equatable { self.query = persisted.query self.tabType = persisted.tabType self.tableName = persisted.tableName - self.primaryKeyColumn = nil + self.primaryKeyColumns = [] // Initialize runtime state with defaults self.lastExecutedAt = nil diff --git a/TablePro/Models/Query/QueryTabState.swift b/TablePro/Models/Query/QueryTabState.swift index b6fc74e63..5856830e4 100644 --- a/TablePro/Models/Query/QueryTabState.swift +++ b/TablePro/Models/Query/QueryTabState.swift @@ -35,7 +35,7 @@ struct TabPendingChanges: Equatable { var insertedRowIndices: Set var modifiedCells: [Int: Set] var insertedRowData: [Int: [String?]] // Lazy storage for inserted row values - var primaryKeyColumn: String? + var primaryKeyColumns: [String] var columns: [String] init() { @@ -44,7 +44,7 @@ struct TabPendingChanges: Equatable { self.insertedRowIndices = [] self.modifiedCells = [:] self.insertedRowData = [:] - self.primaryKeyColumn = nil + self.primaryKeyColumns = [] self.columns = [] } diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index 052752693..2a5e829bc 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -496,7 +496,7 @@ struct MainEditorContentView: View { connectionId: connection.id, databaseType: connection.type, tableName: tab.tableName, - primaryKeyColumn: changeManager.primaryKeyColumn, + primaryKeyColumns: changeManager.primaryKeyColumns, tabType: tab.tabType, showRowNumbers: AppSettingsManager.shared.dataGrid.showRowNumbers, hiddenColumns: columnVisibilityManager.hiddenColumns, diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift index 808cc0770..5ce133009 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift @@ -19,7 +19,7 @@ extension MainContentCoordinator { let columnDefaults: [String: String?] let columnForeignKeys: [String: ForeignKeyInfo] let columnNullable: [String: Bool] - let primaryKeyColumn: String? + let primaryKeyColumns: [String] let approximateRowCount: Int? let columnEnumValues: [String: [String]] } @@ -52,7 +52,7 @@ extension MainContentCoordinator { columnDefaults: defaults, columnForeignKeys: fks, columnNullable: nullable, - primaryKeyColumn: schema.columnInfo.first(where: { $0.isPrimaryKey })?.name, + primaryKeyColumns: schema.columnInfo.filter { $0.isPrimaryKey }.map(\.name), approximateRowCount: schema.approximateRowCount, columnEnumValues: enumValues ) @@ -66,7 +66,7 @@ extension MainContentCoordinator { let tab = tabManager.tabs[idx] guard tab.tableName == tableName, !tab.columnDefaults.isEmpty, - tab.primaryKeyColumn != nil else { + !tab.primaryKeyColumns.isEmpty else { return false } // Ensure every ENUM/SET column has its allowed values loaded @@ -194,25 +194,25 @@ extension MainContentCoordinator { cachedTableColumnNames[cacheKey] = columns } - let resolvedPK: String? - if let pk = metadata?.primaryKeyColumn { - resolvedPK = pk + let resolvedPKs: [String] + if let pks = metadata?.primaryKeyColumns, !pks.isEmpty { + resolvedPKs = pks } else if let defaultPK = PluginManager.shared.defaultPrimaryKeyColumn(for: conn.type) { - resolvedPK = defaultPK + resolvedPKs = [defaultPK] } else { - // Preserve existing PK when metadata is cached and not re-fetched - resolvedPK = tabManager.tabs[idx].primaryKeyColumn + // Preserve existing PKs when metadata is cached and not re-fetched + resolvedPKs = tabManager.tabs[idx].primaryKeyColumns } - if let pk = resolvedPK { - tabManager.tabs[idx].primaryKeyColumn = pk + if !resolvedPKs.isEmpty { + tabManager.tabs[idx].primaryKeyColumns = resolvedPKs } if tabManager.selectedTabId == tabId { changeManager.configureForTable( tableName: tableName ?? "", columns: columns, - primaryKeyColumn: resolvedPK, + primaryKeyColumns: resolvedPKs, databaseType: conn.type ) } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift index 267edc0cb..d498fa129 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift @@ -164,7 +164,7 @@ extension MainContentCoordinator { let pastedRows = rowOperationsManager.pasteRowsFromClipboard( columns: tab.resultColumns, - primaryKeyColumn: changeManager.primaryKeyColumn, + primaryKeyColumns: changeManager.primaryKeyColumns, resultRows: &tab.resultRows ) diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift index e2200831a..952483f54 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift @@ -61,7 +61,9 @@ extension MainContentCoordinator { changeManager.configureForTable( tableName: newTab.tableName ?? "", columns: newTab.resultColumns, - primaryKeyColumn: newTab.primaryKeyColumn ?? newTab.resultColumns.first, + primaryKeyColumns: newTab.primaryKeyColumns.isEmpty + ? newTab.resultColumns.prefix(1).map { $0 } + : newTab.primaryKeyColumns, databaseType: connection.type, triggerReload: false ) diff --git a/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift b/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift index 2bb333dd4..57a0d4586 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift @@ -85,7 +85,7 @@ extension MainContentView { changeManager.configureForTable( tableName: tab.tableName ?? "", columns: newColumns, - primaryKeyColumn: newColumns.first, + primaryKeyColumns: tab.primaryKeyColumns, databaseType: connection.type ) } diff --git a/TablePro/Views/Results/DataGridCoordinator.swift b/TablePro/Views/Results/DataGridCoordinator.swift index a240d2dbf..c00427334 100644 --- a/TablePro/Views/Results/DataGridCoordinator.swift +++ b/TablePro/Views/Results/DataGridCoordinator.swift @@ -42,7 +42,9 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData var connectionId: UUID? var databaseType: DatabaseType? var tableName: String? - var primaryKeyColumn: String? + var primaryKeyColumns: [String] = [] + /// First PK column, for copy-as-SQL and single-column contexts + var primaryKeyColumn: String? { primaryKeyColumns.first } var tabType: TabType? /// Check if undo is available diff --git a/TablePro/Views/Results/DataGridView.swift b/TablePro/Views/Results/DataGridView.swift index 0b19295af..2f140da1a 100644 --- a/TablePro/Views/Results/DataGridView.swift +++ b/TablePro/Views/Results/DataGridView.swift @@ -62,7 +62,7 @@ struct DataGridView: NSViewRepresentable { var connectionId: UUID? var databaseType: DatabaseType? var tableName: String? - var primaryKeyColumn: String? + var primaryKeyColumns: [String] = [] var tabType: TabType? var showRowNumbers: Bool = true var hiddenColumns: Set = [] @@ -327,7 +327,7 @@ struct DataGridView: NSViewRepresentable { coordinator.connectionId = connectionId coordinator.databaseType = databaseType coordinator.tableName = tableName - coordinator.primaryKeyColumn = primaryKeyColumn + coordinator.primaryKeyColumns = primaryKeyColumns coordinator.tabType = tabType coordinator.rebuildVisualStateCache() diff --git a/TablePro/Views/Structure/TableStructureView+Schema.swift b/TablePro/Views/Structure/TableStructureView+Schema.swift index 04f9ebdab..170bed17e 100644 --- a/TablePro/Views/Structure/TableStructureView+Schema.swift +++ b/TablePro/Views/Structure/TableStructureView+Schema.swift @@ -50,6 +50,15 @@ extension TableStructureView { } func executeSchemaChanges() async { + guard !connection.safeModeLevel.blocksAllWrites else { + AlertHelper.showErrorSheet( + title: String(localized: "Read-Only Connection"), + message: String(localized: "Cannot save schema changes: connection is read-only."), + window: NSApp.keyWindow + ) + return + } + let changes = structureChangeManager.getChangesArray() guard !changes.isEmpty else { return } diff --git a/TableProTests/Core/ChangeTracking/AnyChangeManagerTests.swift b/TableProTests/Core/ChangeTracking/AnyChangeManagerTests.swift index afd8d68cf..8ab88219b 100644 --- a/TableProTests/Core/ChangeTracking/AnyChangeManagerTests.swift +++ b/TableProTests/Core/ChangeTracking/AnyChangeManagerTests.swift @@ -17,7 +17,7 @@ struct AnyChangeManagerTests { @Test("DataChangeManager wrapper: hasChanges forwards correctly") func dataManagerHasChangesForwards() { let dataManager = DataChangeManager() - dataManager.configureForTable(tableName: "users", columns: ["id", "name"], primaryKeyColumn: "id") + dataManager.configureForTable(tableName: "users", columns: ["id", "name"], primaryKeyColumns: ["id"]) let wrapper = AnyChangeManager(dataManager: dataManager) #expect(wrapper.hasChanges == false) @@ -31,7 +31,7 @@ struct AnyChangeManagerTests { @Test("DataChangeManager wrapper: reloadVersion forwards correctly") func dataManagerReloadVersionForwards() { let dataManager = DataChangeManager() - dataManager.configureForTable(tableName: "users", columns: ["id", "name"], primaryKeyColumn: "id") + dataManager.configureForTable(tableName: "users", columns: ["id", "name"], primaryKeyColumns: ["id"]) let wrapper = AnyChangeManager(dataManager: dataManager) let initialVersion = wrapper.reloadVersion @@ -43,7 +43,7 @@ struct AnyChangeManagerTests { @Test("isRowDeleted delegates correctly for DataChangeManager") func isRowDeletedDelegatesCorrectly() { let dataManager = DataChangeManager() - dataManager.configureForTable(tableName: "users", columns: ["id", "name"], primaryKeyColumn: "id") + dataManager.configureForTable(tableName: "users", columns: ["id", "name"], primaryKeyColumns: ["id"]) let wrapper = AnyChangeManager(dataManager: dataManager) #expect(wrapper.isRowDeleted(0) == false) @@ -56,7 +56,7 @@ struct AnyChangeManagerTests { @Test("recordCellChange forwards to DataChangeManager") func recordCellChangeForwards() { let dataManager = DataChangeManager() - dataManager.configureForTable(tableName: "users", columns: ["id", "name"], primaryKeyColumn: "id") + dataManager.configureForTable(tableName: "users", columns: ["id", "name"], primaryKeyColumns: ["id"]) let wrapper = AnyChangeManager(dataManager: dataManager) wrapper.recordCellChange(rowIndex: 0, columnIndex: 1, columnName: "name", oldValue: "Alice", newValue: "Bob", originalRow: ["1", "Alice"]) @@ -68,7 +68,7 @@ struct AnyChangeManagerTests { @Test("No retain cycle — wrapper can be deallocated") func noRetainCycleOnWrapper() { let dataManager = DataChangeManager() - dataManager.configureForTable(tableName: "users", columns: ["id", "name"], primaryKeyColumn: "id") + dataManager.configureForTable(tableName: "users", columns: ["id", "name"], primaryKeyColumns: ["id"]) weak var weakWrapper: AnyChangeManager? diff --git a/TableProTests/Core/ChangeTracking/DataChangeManagerClickHouseTests.swift b/TableProTests/Core/ChangeTracking/DataChangeManagerClickHouseTests.swift index 0ee3df4ee..98ecbf9b3 100644 --- a/TableProTests/Core/ChangeTracking/DataChangeManagerClickHouseTests.swift +++ b/TableProTests/Core/ChangeTracking/DataChangeManagerClickHouseTests.swift @@ -19,7 +19,7 @@ struct DataChangeManagerClickHouseTests { manager.configureForTable( tableName: "users", columns: ["id", "name"], - primaryKeyColumn: "id", + primaryKeyColumns: ["id"], databaseType: .clickhouse ) @@ -48,7 +48,7 @@ struct DataChangeManagerClickHouseTests { manager.configureForTable( tableName: "events", columns: ["id", "status"], - primaryKeyColumn: "id", + primaryKeyColumns: ["id"], databaseType: .clickhouse ) @@ -73,7 +73,7 @@ struct DataChangeManagerClickHouseTests { manager.configureForTable( tableName: "users", columns: ["id", "name"], - primaryKeyColumn: "id", + primaryKeyColumns: ["id"], databaseType: .mysql ) @@ -99,7 +99,7 @@ struct DataChangeManagerClickHouseTests { manager.configureForTable( tableName: "logs", columns: ["timestamp", "message"], - primaryKeyColumn: nil, + primaryKeyColumns: [], databaseType: .clickhouse ) diff --git a/TableProTests/Core/ChangeTracking/DataChangeManagerExtendedTests.swift b/TableProTests/Core/ChangeTracking/DataChangeManagerExtendedTests.swift index a7f02256f..2f5e24e1b 100644 --- a/TableProTests/Core/ChangeTracking/DataChangeManagerExtendedTests.swift +++ b/TableProTests/Core/ChangeTracking/DataChangeManagerExtendedTests.swift @@ -20,7 +20,7 @@ struct DataChangeManagerExtendedTests { manager.configureForTable( tableName: "test_table", columns: columns, - primaryKeyColumn: pk + primaryKeyColumns: [pk].compactMap { $0 } ) return manager } @@ -688,7 +688,7 @@ struct DataChangeManagerExtendedTests { manager.configureForTable( tableName: "test", columns: ["a", "b"], - primaryKeyColumn: "a", + primaryKeyColumns: ["a"], triggerReload: false ) #expect(manager.reloadVersion == before) diff --git a/TableProTests/Core/ChangeTracking/DataChangeManagerTests.swift b/TableProTests/Core/ChangeTracking/DataChangeManagerTests.swift index 9b45b7433..afdc8c7ed 100644 --- a/TableProTests/Core/ChangeTracking/DataChangeManagerTests.swift +++ b/TableProTests/Core/ChangeTracking/DataChangeManagerTests.swift @@ -21,7 +21,7 @@ struct DataChangeManagerTests { manager.configureForTable( tableName: "users", columns: ["id", "name", "email"], - primaryKeyColumn: "id", + primaryKeyColumns: ["id"], databaseType: .postgresql ) @@ -37,7 +37,7 @@ struct DataChangeManagerTests { manager.configureForTable( tableName: "users", columns: ["id", "name"], - primaryKeyColumn: "id" + primaryKeyColumns: ["id"] ) manager.recordCellChange( @@ -52,7 +52,7 @@ struct DataChangeManagerTests { manager.configureForTable( tableName: "products", columns: ["id", "title"], - primaryKeyColumn: "id" + primaryKeyColumns: ["id"] ) #expect(!manager.hasChanges) @@ -77,7 +77,7 @@ struct DataChangeManagerTests { manager.configureForTable( tableName: "users", columns: ["id", "name"], - primaryKeyColumn: "id" + primaryKeyColumns: ["id"] ) manager.recordCellChange( @@ -97,7 +97,7 @@ struct DataChangeManagerTests { manager.configureForTable( tableName: "users", columns: ["id", "name"], - primaryKeyColumn: "id" + primaryKeyColumns: ["id"] ) manager.recordCellChange( @@ -123,7 +123,7 @@ struct DataChangeManagerTests { manager.configureForTable( tableName: "users", columns: ["id", "name"], - primaryKeyColumn: "id" + primaryKeyColumns: ["id"] ) manager.recordCellChange( @@ -144,7 +144,7 @@ struct DataChangeManagerTests { manager.configureForTable( tableName: "users", columns: ["id", "name"], - primaryKeyColumn: "id" + primaryKeyColumns: ["id"] ) manager.recordCellChange( @@ -175,7 +175,7 @@ struct DataChangeManagerTests { manager.configureForTable( tableName: "users", columns: ["id", "name"], - primaryKeyColumn: "id" + primaryKeyColumns: ["id"] ) manager.recordCellChange( @@ -205,7 +205,7 @@ struct DataChangeManagerTests { manager.configureForTable( tableName: "users", columns: ["id", "name"], - primaryKeyColumn: "id" + primaryKeyColumns: ["id"] ) manager.recordCellChange( @@ -235,7 +235,7 @@ struct DataChangeManagerTests { manager.configureForTable( tableName: "users", columns: ["id", "name"], - primaryKeyColumn: "id" + primaryKeyColumns: ["id"] ) manager.recordCellChange( @@ -257,7 +257,7 @@ struct DataChangeManagerTests { manager.configureForTable( tableName: "users", columns: ["id", "name"], - primaryKeyColumn: "id" + primaryKeyColumns: ["id"] ) manager.recordRowDeletion(rowIndex: 0, originalRow: ["1", "Alice"]) @@ -271,7 +271,7 @@ struct DataChangeManagerTests { manager.configureForTable( tableName: "users", columns: ["id", "name"], - primaryKeyColumn: "id" + primaryKeyColumns: ["id"] ) manager.recordCellChange( @@ -297,7 +297,7 @@ struct DataChangeManagerTests { manager.configureForTable( tableName: "users", columns: ["id", "name"], - primaryKeyColumn: "id" + primaryKeyColumns: ["id"] ) manager.recordRowDeletion(rowIndex: 2, originalRow: ["3", "Charlie"]) @@ -314,7 +314,7 @@ struct DataChangeManagerTests { manager.configureForTable( tableName: "users", columns: ["id", "name"], - primaryKeyColumn: "id" + primaryKeyColumns: ["id"] ) let rows = [ @@ -338,7 +338,7 @@ struct DataChangeManagerTests { manager.configureForTable( tableName: "users", columns: ["id", "name"], - primaryKeyColumn: "id" + primaryKeyColumns: ["id"] ) manager.recordCellChange( @@ -369,7 +369,7 @@ struct DataChangeManagerTests { manager.configureForTable( tableName: "users", columns: ["id", "name"], - primaryKeyColumn: "id" + primaryKeyColumns: ["id"] ) manager.recordCellChange( @@ -393,7 +393,7 @@ struct DataChangeManagerTests { manager.configureForTable( tableName: "users", columns: ["id", "name"], - primaryKeyColumn: "id" + primaryKeyColumns: ["id"] ) manager.recordCellChange( @@ -418,7 +418,7 @@ struct DataChangeManagerTests { manager.configureForTable( tableName: "users", columns: ["id", "name"], - primaryKeyColumn: "id" + primaryKeyColumns: ["id"] ) manager.recordCellChange( @@ -443,7 +443,7 @@ struct DataChangeManagerTests { manager.configureForTable( tableName: "users", columns: ["id", "name"], - primaryKeyColumn: "id" + primaryKeyColumns: ["id"] ) manager.recordCellChange( @@ -463,7 +463,7 @@ struct DataChangeManagerTests { manager.configureForTable( tableName: "users", columns: ["id", "name"], - primaryKeyColumn: "id" + primaryKeyColumns: ["id"] ) manager.recordCellChange( @@ -487,7 +487,7 @@ struct DataChangeManagerTests { manager.configureForTable( tableName: "users", columns: ["id", "name"], - primaryKeyColumn: "id" + primaryKeyColumns: ["id"] ) manager.recordCellChange( @@ -509,7 +509,7 @@ struct DataChangeManagerTests { manager.configureForTable( tableName: "users", columns: ["id", "name"], - primaryKeyColumn: "id" + primaryKeyColumns: ["id"] ) manager.recordCellChange( @@ -550,7 +550,7 @@ struct DataChangeManagerTests { manager.configureForTable( tableName: "users", columns: ["id", "name"], - primaryKeyColumn: "id" + primaryKeyColumns: ["id"] ) let initialVersion = manager.reloadVersion @@ -572,7 +572,7 @@ struct DataChangeManagerTests { manager.configureForTable( tableName: "users", columns: ["id", "name"], - primaryKeyColumn: "id" + primaryKeyColumns: ["id"] ) manager.recordCellChange( diff --git a/TableProTests/Core/ChangeTracking/SQLStatementGeneratorCompositePKTests.swift b/TableProTests/Core/ChangeTracking/SQLStatementGeneratorCompositePKTests.swift new file mode 100644 index 000000000..82dc730af --- /dev/null +++ b/TableProTests/Core/ChangeTracking/SQLStatementGeneratorCompositePKTests.swift @@ -0,0 +1,527 @@ +// +// SQLStatementGeneratorCompositePKTests.swift +// TableProTests +// +// Tests for composite primary key support in UPDATE and DELETE generation. +// + +@testable import TablePro +import Testing + +@Suite("SQL Statement Generator — Composite Primary Key") +struct SQLStatementGeneratorCompositePKTests { + // MARK: - Helpers + + private func makeGenerator( + tableName: String = "order_items", + columns: [String] = ["order_id", "product_id", "quantity", "price"], + primaryKeyColumns: [String] = ["order_id", "product_id"], + databaseType: DatabaseType = .mysql + ) -> SQLStatementGenerator { + SQLStatementGenerator( + tableName: tableName, + columns: columns, + primaryKeyColumns: primaryKeyColumns, + databaseType: databaseType, + dialect: nil + ) + } + + private func makeUpdateChange( + rowIndex: Int = 0, + columnIndex: Int, + columnName: String, + oldValue: String?, + newValue: String?, + originalRow: [String?] + ) -> RowChange { + RowChange( + rowIndex: rowIndex, + type: .update, + cellChanges: [CellChange( + rowIndex: rowIndex, columnIndex: columnIndex, + columnName: columnName, oldValue: oldValue, newValue: newValue + )], + originalRow: originalRow + ) + } + + private func makeMultiCellUpdateChange( + rowIndex: Int = 0, + cellChanges: [CellChange], + originalRow: [String?] + ) -> RowChange { + RowChange( + rowIndex: rowIndex, + type: .update, + cellChanges: cellChanges, + originalRow: originalRow + ) + } + + private func makeDeleteChange(rowIndex: Int = 0, originalRow: [String?]) -> RowChange { + RowChange(rowIndex: rowIndex, type: .delete, cellChanges: [], originalRow: originalRow) + } + + private func generate( + _ changes: [RowChange], + generator: SQLStatementGenerator, + deletedRowIndices: Set = [], + insertedRowIndices: Set = [] + ) -> [ParameterizedStatement] { + generator.generateStatements( + from: changes, + insertedRowData: [:], + deletedRowIndices: deletedRowIndices, + insertedRowIndices: insertedRowIndices + ) + } + + // MARK: - UPDATE: Composite PK WHERE Clause + + @Test("UPDATE with 2-column composite PK produces AND in WHERE") + func updateCompositePKBasic() { + let gen = makeGenerator() + let stmts = generate([ + makeUpdateChange( + columnIndex: 2, columnName: "quantity", + oldValue: "5", newValue: "10", + originalRow: ["1", "42", "5", "9.99"] + ), + ], generator: gen) + + #expect(stmts.count == 1) + #expect(stmts[0].sql.contains("`order_id` = ?")) + #expect(stmts[0].sql.contains("`product_id` = ?")) + #expect(stmts[0].sql.contains(" AND ")) + #expect(stmts[0].parameters.count == 3) // SET quantity + WHERE order_id, product_id + } + + @Test("UPDATE with 3-column composite PK produces multiple ANDs") + func updateThreeColumnCompositePK() { + let gen = makeGenerator( + columns: ["tenant_id", "user_id", "role_id", "active"], + primaryKeyColumns: ["tenant_id", "user_id", "role_id"] + ) + let stmts = generate([ + makeUpdateChange( + columnIndex: 3, columnName: "active", + oldValue: "1", newValue: "0", + originalRow: ["t1", "u1", "r1", "1"] + ), + ], generator: gen) + + #expect(stmts.count == 1) + let sql = stmts[0].sql + #expect(sql.contains("`tenant_id` = ?")) + #expect(sql.contains("`user_id` = ?")) + #expect(sql.contains("`role_id` = ?")) + #expect(stmts[0].parameters.count == 4) // SET active + 3 PK values + } + + @Test("UPDATE preserves correct parameter order: SET values before WHERE values") + func updateParameterOrder() { + let gen = makeGenerator() + let stmts = generate([ + makeUpdateChange( + columnIndex: 2, columnName: "quantity", + oldValue: "5", newValue: "10", + originalRow: ["1", "42", "5", "9.99"] + ), + ], generator: gen) + + #expect(stmts.count == 1) + let params = stmts[0].parameters + #expect(params[0] as? String == "10") // SET quantity = ? + #expect(params[1] as? String == "1") // WHERE order_id = ? + #expect(params[2] as? String == "42") // AND product_id = ? + } + + @Test("UPDATE multiple columns on same row with composite PK") + func updateMultipleColumnsCompositePK() { + let gen = makeGenerator() + let stmts = generate([ + makeMultiCellUpdateChange( + cellChanges: [ + CellChange(rowIndex: 0, columnIndex: 2, columnName: "quantity", oldValue: "5", newValue: "10"), + CellChange(rowIndex: 0, columnIndex: 3, columnName: "price", oldValue: "9.99", newValue: "12.99"), + ], + originalRow: ["1", "42", "5", "9.99"] + ), + ], generator: gen) + + #expect(stmts.count == 1) + let sql = stmts[0].sql + #expect(sql.contains("`quantity` = ?")) + #expect(sql.contains("`price` = ?")) + #expect(sql.contains("`order_id` = ?")) + #expect(sql.contains("`product_id` = ?")) + #expect(stmts[0].parameters.count == 4) // 2 SET + 2 WHERE + } + + @Test("UPDATE where user edits a PK column uses original value in WHERE") + func updateEditsPKColumn() { + let gen = makeGenerator() + let stmts = generate([ + makeUpdateChange( + columnIndex: 1, columnName: "product_id", + oldValue: "42", newValue: "99", + originalRow: ["1", "42", "5", "9.99"] + ), + ], generator: gen) + + #expect(stmts.count == 1) + let params = stmts[0].parameters + // SET product_id = 99 (new), WHERE order_id = 1, product_id = 42 (original) + #expect(params[0] as? String == "99") // SET + #expect(params[1] as? String == "1") // WHERE order_id (from originalRow) + #expect(params[2] as? String == "42") // WHERE product_id (from originalRow) + } + + @Test("Multiple UPDATE changes generate separate statements") + func multipleUpdatesCompositePK() { + let gen = makeGenerator() + let stmts = generate([ + makeUpdateChange( + rowIndex: 0, columnIndex: 2, columnName: "quantity", + oldValue: "5", newValue: "10", + originalRow: ["1", "42", "5", "9.99"] + ), + makeUpdateChange( + rowIndex: 1, columnIndex: 2, columnName: "quantity", + oldValue: "3", newValue: "7", + originalRow: ["1", "43", "3", "4.99"] + ), + ], generator: gen) + + #expect(stmts.count == 2) + // First UPDATE: WHERE order_id=1 AND product_id=42 + #expect(stmts[0].parameters[1] as? String == "1") + #expect(stmts[0].parameters[2] as? String == "42") + // Second UPDATE: WHERE order_id=1 AND product_id=43 + #expect(stmts[1].parameters[1] as? String == "1") + #expect(stmts[1].parameters[2] as? String == "43") + } + + // MARK: - UPDATE: Database Dialects + + @Test("PostgreSQL UPDATE with composite PK uses $N placeholders") + func updateCompositePKPostgreSQL() { + let gen = makeGenerator(databaseType: .postgresql) + let stmts = generate([ + makeUpdateChange( + columnIndex: 2, columnName: "quantity", + oldValue: "5", newValue: "10", + originalRow: ["1", "42", "5", "9.99"] + ), + ], generator: gen) + + #expect(stmts.count == 1) + let sql = stmts[0].sql + #expect(sql.contains("$1")) // SET quantity + #expect(sql.contains("$2")) // WHERE order_id + #expect(sql.contains("$3")) // AND product_id + } + + @Test("MSSQL UPDATE with composite PK uses bracket quoting") + func updateCompositePKMSSQL() { + let gen = makeGenerator(databaseType: .mssql) + let stmts = generate([ + makeUpdateChange( + columnIndex: 2, columnName: "quantity", + oldValue: "5", newValue: "10", + originalRow: ["1", "42", "5", "9.99"] + ), + ], generator: gen) + + #expect(stmts.count == 1) + let sql = stmts[0].sql + #expect(sql.contains("[order_id] = ?")) + #expect(sql.contains("[product_id] = ?")) + } + + // MARK: - DELETE: Composite PK + + @Test("Single row DELETE with composite PK uses AND") + func deleteSingleRowCompositePK() { + let gen = makeGenerator() + let stmts = generate( + [makeDeleteChange(rowIndex: 0, originalRow: ["1", "42", "5", "9.99"])], + generator: gen, + deletedRowIndices: [0] + ) + + #expect(stmts.count == 1) + let sql = stmts[0].sql + #expect(sql.hasPrefix("DELETE FROM")) + #expect(sql.contains("`order_id` = ?")) + #expect(sql.contains("`product_id` = ?")) + #expect(sql.contains(" AND ")) + #expect(!sql.contains("`quantity`")) + #expect(!sql.contains("`price`")) + #expect(stmts[0].parameters.count == 2) + } + + @Test("Batch DELETE with composite PK: (AND) per row, OR between rows") + func batchDeleteCompositePK() { + let gen = makeGenerator() + let stmts = generate( + [ + makeDeleteChange(rowIndex: 0, originalRow: ["1", "42", "5", "9.99"]), + makeDeleteChange(rowIndex: 1, originalRow: ["1", "43", "3", "4.99"]), + makeDeleteChange(rowIndex: 2, originalRow: ["2", "42", "1", "7.50"]), + ], + generator: gen, + deletedRowIndices: [0, 1, 2] + ) + + #expect(stmts.count == 1) + let sql = stmts[0].sql + #expect(sql.contains("(")) + #expect(sql.contains(")")) + #expect(sql.contains(" OR ")) + #expect(stmts[0].parameters.count == 6) // 3 rows × 2 PK columns + } + + @Test("Batch DELETE with composite PK on PostgreSQL uses $N") + func batchDeleteCompositePKPostgreSQL() { + let gen = makeGenerator(databaseType: .postgresql) + let stmts = generate( + [ + makeDeleteChange(rowIndex: 0, originalRow: ["1", "42", "5", "9.99"]), + makeDeleteChange(rowIndex: 1, originalRow: ["1", "43", "3", "4.99"]), + ], + generator: gen, + deletedRowIndices: [0, 1] + ) + + #expect(stmts.count == 1) + let sql = stmts[0].sql + #expect(sql.contains("$1")) // row 1 order_id + #expect(sql.contains("$2")) // row 1 product_id + #expect(sql.contains("$3")) // row 2 order_id + #expect(sql.contains("$4")) // row 2 product_id + } + + // MARK: - Single PK Regression + + @Test("Single PK UPDATE still works (regression)") + func singlePKUpdateRegression() { + let gen = makeGenerator( + tableName: "users", + columns: ["id", "name", "email"], + primaryKeyColumns: ["id"] + ) + let stmts = generate([ + makeUpdateChange( + columnIndex: 1, columnName: "name", + oldValue: "John", newValue: "Jane", + originalRow: ["1", "John", "john@test.com"] + ), + ], generator: gen) + + #expect(stmts.count == 1) + #expect(stmts[0].sql.contains("WHERE `id` = ?")) + #expect(!stmts[0].sql.contains(" AND ")) + #expect(stmts[0].parameters.count == 2) + } + + @Test("Single PK batch DELETE no parentheses (regression)") + func singlePKBatchDeleteRegression() { + let gen = makeGenerator( + tableName: "users", + columns: ["id", "name", "email"], + primaryKeyColumns: ["id"] + ) + let stmts = generate( + [ + makeDeleteChange(rowIndex: 0, originalRow: ["1", "John", "john@test.com"]), + makeDeleteChange(rowIndex: 1, originalRow: ["2", "Jane", "jane@test.com"]), + ], + generator: gen, + deletedRowIndices: [0, 1] + ) + + #expect(stmts.count == 1) + let sql = stmts[0].sql + // Single PK: no parentheses around conditions + #expect(!sql.contains("(")) + #expect(sql.contains("`id` = ?")) + #expect(sql.contains(" OR ")) + } + + // MARK: - No PK Fallback + + @Test("No PK UPDATE falls back to all-column WHERE") + func noPKUpdateFallback() { + let gen = makeGenerator( + tableName: "logs", + columns: ["ts", "message", "level"], + primaryKeyColumns: [] + ) + let stmts = generate([ + makeUpdateChange( + columnIndex: 2, columnName: "level", + oldValue: "info", newValue: "warn", + originalRow: ["2024-01-01", "hello", "info"] + ), + ], generator: gen) + + #expect(stmts.count == 1) + let sql = stmts[0].sql + #expect(sql.contains("`ts` = ?")) + #expect(sql.contains("`message` = ?")) + #expect(sql.contains("`level` = ?")) + } + + @Test("No PK DELETE uses individual per-row statements with all columns") + func noPKDeleteFallback() { + let gen = makeGenerator( + tableName: "logs", + columns: ["ts", "message", "level"], + primaryKeyColumns: [] + ) + let stmts = generate( + [ + makeDeleteChange(rowIndex: 0, originalRow: ["2024-01-01", "hello", "info"]), + makeDeleteChange(rowIndex: 1, originalRow: ["2024-01-02", "world", "warn"]), + ], + generator: gen, + deletedRowIndices: [0, 1] + ) + + // No PK batch delete returns nil → individual deletes + #expect(stmts.count == 2) + #expect(stmts[0].sql.contains("`ts` = ?")) + #expect(stmts[0].sql.contains("`message` = ?")) + #expect(stmts[0].sql.contains("`level` = ?")) + } + + @Test("No PK fallback handles NULL values with IS NULL") + func noPKFallbackNullHandling() { + let gen = makeGenerator( + tableName: "logs", + columns: ["ts", "message", "level"], + primaryKeyColumns: [] + ) + let stmts = generate([ + makeUpdateChange( + columnIndex: 2, columnName: "level", + oldValue: nil, newValue: "warn", + originalRow: ["2024-01-01", nil, nil] + ), + ], generator: gen) + + #expect(stmts.count == 1) + let sql = stmts[0].sql + #expect(sql.contains("`message` IS NULL")) + #expect(sql.contains("`level` IS NULL")) + #expect(sql.contains("`ts` = ?")) + } + + // MARK: - Edge Cases + + @Test("Composite PK with NULL value in one PK column skips UPDATE") + func compositePKNullValueSkipsUpdate() { + let gen = makeGenerator() + let stmts = generate([ + makeUpdateChange( + columnIndex: 2, columnName: "quantity", + oldValue: "5", newValue: "10", + originalRow: ["1", nil, "5", "9.99"] + ), + ], generator: gen) + + #expect(stmts.isEmpty) + } + + @Test("Composite PK with NULL in one PK column skips batch DELETE for that row") + func compositePKNullValueInBatchDelete() { + let gen = makeGenerator() + let stmts = generate( + [ + makeDeleteChange(rowIndex: 0, originalRow: ["1", nil, "5", "9.99"]), + makeDeleteChange(rowIndex: 1, originalRow: ["1", "43", "3", "4.99"]), + ], + generator: gen, + deletedRowIndices: [0, 1] + ) + + // Row 0 has NULL PK → skipped in batch, only row 1 survives + #expect(stmts.count == 1) + #expect(stmts[0].parameters.count == 2) // Only row 1's 2 PK values + } + + @Test("UPDATE without originalRow falls back to cellChanges for PK value") + func updateWithoutOriginalRowUsesCellChanges() { + let gen = makeGenerator() + let change = RowChange( + rowIndex: 0, + type: .update, + cellChanges: [ + CellChange(rowIndex: 0, columnIndex: 0, columnName: "order_id", oldValue: "1", newValue: "1"), + CellChange(rowIndex: 0, columnIndex: 1, columnName: "product_id", oldValue: "42", newValue: "42"), + CellChange(rowIndex: 0, columnIndex: 2, columnName: "quantity", oldValue: "5", newValue: "10"), + ], + originalRow: nil + ) + + let stmts = generate([change], generator: gen) + + #expect(stmts.count == 1) + #expect(stmts[0].sql.contains("`order_id` = ?")) + #expect(stmts[0].sql.contains("`product_id` = ?")) + } + + @Test("UPDATE without originalRow and missing PK in cellChanges is skipped") + func updateWithoutOriginalRowMissingPKSkipped() { + let gen = makeGenerator() + let change = RowChange( + rowIndex: 0, + type: .update, + cellChanges: [ + CellChange(rowIndex: 0, columnIndex: 2, columnName: "quantity", oldValue: "5", newValue: "10"), + ], + originalRow: nil // No originalRow, and only quantity in cellChanges — missing PK columns + ) + + let stmts = generate([change], generator: gen) + + #expect(stmts.isEmpty) + } + + @Test("Mixed INSERT + UPDATE + DELETE with composite PK generates correct statements") + func mixedOperationsCompositePK() { + let gen = makeGenerator() + + let insertChange = RowChange(rowIndex: 3, type: .insert, cellChanges: []) + let updateChange = makeUpdateChange( + rowIndex: 0, columnIndex: 2, columnName: "quantity", + oldValue: "5", newValue: "10", + originalRow: ["1", "42", "5", "9.99"] + ) + let deleteChange = makeDeleteChange(rowIndex: 1, originalRow: ["1", "43", "3", "4.99"]) + + let stmts = gen.generateStatements( + from: [insertChange, updateChange, deleteChange], + insertedRowData: [3: ["2", "99", "1", "5.00"]], + deletedRowIndices: [1], + insertedRowIndices: [3] + ) + + // INSERT + UPDATE + DELETE = 3 statements + #expect(stmts.count == 3) + + let insertSQL = stmts.first { $0.sql.hasPrefix("INSERT") } + let updateSQL = stmts.first { $0.sql.hasPrefix("UPDATE") } + let deleteSQL = stmts.first { $0.sql.hasPrefix("DELETE") } + + #expect(insertSQL != nil) + #expect(updateSQL != nil) + #expect(deleteSQL != nil) + #expect(updateSQL!.sql.contains("`order_id` = ?")) + #expect(updateSQL!.sql.contains("`product_id` = ?")) + #expect(deleteSQL!.sql.contains("`order_id` = ?")) + #expect(deleteSQL!.sql.contains("`product_id` = ?")) + } +} diff --git a/TableProTests/Core/ChangeTracking/SQLStatementGeneratorMSSQLTests.swift b/TableProTests/Core/ChangeTracking/SQLStatementGeneratorMSSQLTests.swift index 47481be42..f0c05340f 100644 --- a/TableProTests/Core/ChangeTracking/SQLStatementGeneratorMSSQLTests.swift +++ b/TableProTests/Core/ChangeTracking/SQLStatementGeneratorMSSQLTests.swift @@ -16,12 +16,12 @@ struct SQLStatementGeneratorMSSQLTests { private func makeGenerator( tableName: String = "users", columns: [String] = ["id", "name", "email"], - primaryKeyColumn: String? = "id" + primaryKeyColumns: [String] = ["id"] ) -> SQLStatementGenerator { SQLStatementGenerator( tableName: tableName, columns: columns, - primaryKeyColumn: primaryKeyColumn, + primaryKeyColumns: primaryKeyColumns, databaseType: .mssql, dialect: nil ) diff --git a/TableProTests/Core/ChangeTracking/SQLStatementGeneratorNoPKTests.swift b/TableProTests/Core/ChangeTracking/SQLStatementGeneratorNoPKTests.swift index be86eaecb..ef1a43160 100644 --- a/TableProTests/Core/ChangeTracking/SQLStatementGeneratorNoPKTests.swift +++ b/TableProTests/Core/ChangeTracking/SQLStatementGeneratorNoPKTests.swift @@ -16,13 +16,13 @@ struct SQLStatementGeneratorNoPKTests { private func makeGenerator( tableName: String = "users", columns: [String] = ["id", "name", "email"], - primaryKeyColumn: String? = nil, + primaryKeyColumns: [String] = [], databaseType: DatabaseType = .mysql ) -> SQLStatementGenerator { SQLStatementGenerator( tableName: tableName, columns: columns, - primaryKeyColumn: primaryKeyColumn, + primaryKeyColumns: primaryKeyColumns, databaseType: databaseType, dialect: nil ) diff --git a/TableProTests/Core/ChangeTracking/SQLStatementGeneratorPKRegressionTests.swift b/TableProTests/Core/ChangeTracking/SQLStatementGeneratorPKRegressionTests.swift index 49e57d0ec..fb5195ab8 100644 --- a/TableProTests/Core/ChangeTracking/SQLStatementGeneratorPKRegressionTests.swift +++ b/TableProTests/Core/ChangeTracking/SQLStatementGeneratorPKRegressionTests.swift @@ -14,13 +14,13 @@ struct SQLStatementGeneratorPKRegressionTests { private func makeGenerator( tableName: String = "users", columns: [String] = ["id", "name", "email"], - primaryKeyColumn: String? = "id", + primaryKeyColumns: [String] = ["id"], databaseType: DatabaseType = .postgresql ) -> SQLStatementGenerator { SQLStatementGenerator( tableName: tableName, columns: columns, - primaryKeyColumn: primaryKeyColumn, + primaryKeyColumns: primaryKeyColumns, databaseType: databaseType, dialect: nil ) diff --git a/TableProTests/Core/ChangeTracking/SQLStatementGeneratorParameterStyleTests.swift b/TableProTests/Core/ChangeTracking/SQLStatementGeneratorParameterStyleTests.swift index 6ee99d581..9c0c7bc7e 100644 --- a/TableProTests/Core/ChangeTracking/SQLStatementGeneratorParameterStyleTests.swift +++ b/TableProTests/Core/ChangeTracking/SQLStatementGeneratorParameterStyleTests.swift @@ -18,14 +18,14 @@ struct SQLStatementGeneratorParameterStyleTests { private func makeGenerator( tableName: String = "users", columns: [String] = ["id", "name", "email"], - primaryKeyColumn: String? = "id", + primaryKeyColumns: [String] = ["id"], databaseType: DatabaseType = .mysql, parameterStyle: ParameterStyle? = nil ) -> SQLStatementGenerator { SQLStatementGenerator( tableName: tableName, columns: columns, - primaryKeyColumn: primaryKeyColumn, + primaryKeyColumns: primaryKeyColumns, databaseType: databaseType, parameterStyle: parameterStyle, dialect: nil diff --git a/TableProTests/Core/ChangeTracking/SQLStatementGeneratorTests.swift b/TableProTests/Core/ChangeTracking/SQLStatementGeneratorTests.swift index 2eea5633a..905e7f0b6 100644 --- a/TableProTests/Core/ChangeTracking/SQLStatementGeneratorTests.swift +++ b/TableProTests/Core/ChangeTracking/SQLStatementGeneratorTests.swift @@ -17,13 +17,13 @@ struct SQLStatementGeneratorTests { private func makeGenerator( tableName: String = "users", columns: [String] = ["id", "name", "email"], - primaryKeyColumn: String? = "id", + primaryKeyColumns: [String] = ["id"], databaseType: DatabaseType = .mysql ) -> SQLStatementGenerator { return SQLStatementGenerator( tableName: tableName, columns: columns, - primaryKeyColumn: primaryKeyColumn, + primaryKeyColumns: primaryKeyColumns, databaseType: databaseType, dialect: nil ) @@ -574,7 +574,7 @@ struct SQLStatementGeneratorTests { @Test("Individual delete without PK matches all columns") func testIndividualDeleteNoPK() { - let generator = makeGenerator(primaryKeyColumn: nil) + let generator = makeGenerator(primaryKeyColumns: []) let changes: [RowChange] = [ RowChange( rowIndex: 0, @@ -602,7 +602,7 @@ struct SQLStatementGeneratorTests { @Test("Individual delete with NULL column uses IS NULL") func testIndividualDeleteWithNull() { - let generator = makeGenerator(primaryKeyColumn: nil) + let generator = makeGenerator(primaryKeyColumns: []) let changes: [RowChange] = [ RowChange( rowIndex: 0, @@ -627,7 +627,7 @@ struct SQLStatementGeneratorTests { @Test("MySQL/MariaDB individual delete adds LIMIT 1") func testDeleteMySQLLimitOne() { - let generator = makeGenerator(primaryKeyColumn: nil, databaseType: .mysql) + let generator = makeGenerator(primaryKeyColumns: [], databaseType: .mysql) let changes: [RowChange] = [ RowChange( rowIndex: 0, @@ -650,7 +650,7 @@ struct SQLStatementGeneratorTests { @Test("PostgreSQL delete no LIMIT 1") func testDeletePostgreSQLNoLimit() { - let generator = makeGenerator(primaryKeyColumn: nil, databaseType: .postgresql) + let generator = makeGenerator(primaryKeyColumns: [], databaseType: .postgresql) let changes: [RowChange] = [ RowChange( rowIndex: 0, @@ -1130,7 +1130,7 @@ struct SQLStatementGeneratorTests { @Test("Redshift delete no LIMIT 1") func testDeleteRedshiftNoLimit() { - let generator = makeGenerator(primaryKeyColumn: nil, databaseType: .redshift) + let generator = makeGenerator(primaryKeyColumns: [], databaseType: .redshift) let changes: [RowChange] = [ RowChange( rowIndex: 0, @@ -1217,7 +1217,7 @@ struct SQLStatementGeneratorTests { let generator = makeGenerator( tableName: "connections", columns: ["id", "database", "table", "order"], - primaryKeyColumn: "id" + primaryKeyColumns: ["id"] ) let changes: [RowChange] = [ RowChange( @@ -1249,7 +1249,7 @@ struct SQLStatementGeneratorTests { let generator = makeGenerator( tableName: "connections", columns: ["id", "database", "order"], - primaryKeyColumn: "id" + primaryKeyColumns: ["id"] ) let insertedRowData: [Int: [String?]] = [ 0: ["1", "mydb", "5"] @@ -1278,7 +1278,7 @@ struct SQLStatementGeneratorTests { let generator = makeGenerator( tableName: "connections", columns: ["id", "database", "select"], - primaryKeyColumn: nil + primaryKeyColumns: [] ) let changes: [RowChange] = [ RowChange( @@ -1307,7 +1307,7 @@ struct SQLStatementGeneratorTests { let generator = makeGenerator( tableName: "connections", columns: ["id", "database", "order"], - primaryKeyColumn: "id", + primaryKeyColumns: ["id"], databaseType: .postgresql ) let changes: [RowChange] = [ diff --git a/TableProTests/Core/Services/RowOperationsManagerCopyTests.swift b/TableProTests/Core/Services/RowOperationsManagerCopyTests.swift index 93200cb37..1acfcb9b0 100644 --- a/TableProTests/Core/Services/RowOperationsManagerCopyTests.swift +++ b/TableProTests/Core/Services/RowOperationsManagerCopyTests.swift @@ -33,7 +33,7 @@ struct RowOperationsManagerCopyTests { changeManager.configureForTable( tableName: "users", columns: ["id", "name", "email"], - primaryKeyColumn: "id", + primaryKeyColumns: ["id"], databaseType: .mysql ) let manager = RowOperationsManager(changeManager: changeManager) diff --git a/TableProTests/Core/Services/RowOperationsManagerTests.swift b/TableProTests/Core/Services/RowOperationsManagerTests.swift index e59bbb301..025107eeb 100644 --- a/TableProTests/Core/Services/RowOperationsManagerTests.swift +++ b/TableProTests/Core/Services/RowOperationsManagerTests.swift @@ -19,7 +19,7 @@ struct RowOperationsManagerTests { changeManager.configureForTable( tableName: "users", columns: ["id", "name", "email"], - primaryKeyColumn: "id", + primaryKeyColumns: ["id"], databaseType: .mysql ) let manager = RowOperationsManager(changeManager: changeManager) diff --git a/TableProTests/Core/Utilities/SQLRowToStatementConverterTests.swift b/TableProTests/Core/Utilities/SQLRowToStatementConverterTests.swift index 57516383b..4aa98782d 100644 --- a/TableProTests/Core/Utilities/SQLRowToStatementConverterTests.swift +++ b/TableProTests/Core/Utilities/SQLRowToStatementConverterTests.swift @@ -123,14 +123,14 @@ struct SQLRowToStatementConverterTests { @Test("UPDATE without primary key uses all columns in SET and WHERE") func updateWithoutPrimaryKey() { - let converter = makeConverter(primaryKeyColumn: nil) + let converter = makeConverter(primaryKeyColumns: []) 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) + let converter = makeConverter(primaryKeyColumns: []) 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';") } @@ -199,7 +199,7 @@ struct SQLRowToStatementConverterTests { func updatePkNotInColumnsFallsBack() { let converter = makeConverter( columns: ["name", "email"], - primaryKeyColumn: "id", + primaryKeyColumns: ["id"], databaseType: .mysql ) let result = converter.generateUpdates(rows: [["Alice", "alice@example.com"]]) @@ -219,7 +219,7 @@ struct SQLRowToStatementConverterTests { func rowCapAt50k() { let converter = makeConverter( columns: ["id", "name"], - primaryKeyColumn: "id" + primaryKeyColumns: ["id"] ) let rows: [[String?]] = (1...50_001).map { i in ["\(i)", "name\(i)"] } let result = converter.generateInserts(rows: rows) diff --git a/TableProTests/Views/Results/RowProviderSyncTests.swift b/TableProTests/Views/Results/RowProviderSyncTests.swift index 8ed6097ba..a7b8fe608 100644 --- a/TableProTests/Views/Results/RowProviderSyncTests.swift +++ b/TableProTests/Views/Results/RowProviderSyncTests.swift @@ -24,7 +24,7 @@ struct RowProviderSyncTests { let rows = TestFixtures.makeRows(count: rowCount, columns: columns) let provider = InMemoryRowProvider(rows: rows, columns: columns) let manager = DataChangeManager() - manager.configureForTable(tableName: "test", columns: columns, primaryKeyColumn: "id") + manager.configureForTable(tableName: "test", columns: columns, primaryKeyColumns: ["id"]) return (manager, provider) }