Skip to content

Commit 1dce3e1

Browse files
authored
fix: composite primary key tables update/delete wrong rows (#742)
* fix: composite primary key tables update/delete wrong rows Thread primaryKeyColumns: [String] through the entire editing pipeline, replacing primaryKeyColumn: String? which silently dropped all but the first PK column. UPDATE and DELETE now use all PK columns in WHERE. Also: structure view saves now respect safe mode on read-only connections, and discardChanges() clears stale changedRowIndices. * fix: handleColumnChange was overwriting composite PKs with first column The .onChange handler for column changes unconditionally set primaryKeyColumns to [firstColumn], ignoring the actual schema PKs. Now reads from tab.primaryKeyColumns which preserves the full composite PK set from schema detection. * fix: SQLite/D1 composite PK detection only matched first column PRAGMA table_info returns pk as position (1, 2, 3...) not boolean. The check row[5] == "1" only matched the first PK column. Changed to row[5] != "0" to detect all columns in a composite primary key. * fix: review feedback — guard unknown PK column, simplify TabSwitch and changelog
1 parent 157a640 commit 1dce3e1

31 files changed

+717
-143
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1212
- Fix AI chat hanging the app during streaming, schema fetch, and conversation loading (#735)
1313
- SSH Agent auth: fall back to key file from `~/.ssh/config` or default paths when agent has no loaded identities (#729)
1414
- 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)
15+
- Composite primary key tables: editing or deleting a row affects all rows sharing the first PK value instead of just the target row
16+
- Structure view saves bypass safe mode on read-only connections
1517

1618
## [0.31.4] - 2026-04-14
1719

Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver.swift

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,8 @@ final class CloudflareD1PluginDriver: PluginDatabaseDriver, @unchecked Sendable
214214
}
215215

216216
let isNullable = row[3] == "0"
217-
let isPrimaryKey = row[5] == "1"
217+
// PRAGMA table_info pk column: 0 = not PK, 1+ = position in composite PK
218+
let isPrimaryKey = row[5] != nil && row[5] != "0"
218219
let defaultValue = row[4]
219220

220221
return PluginColumnInfo(
@@ -248,7 +249,8 @@ final class CloudflareD1PluginDriver: PluginDatabaseDriver, @unchecked Sendable
248249

249250
let isNullable = row[4] == "0"
250251
let defaultValue = row[5]
251-
let isPrimaryKey = row[6] == "1"
252+
// PRAGMA table_info pk column: 0 = not PK, 1+ = position in composite PK
253+
let isPrimaryKey = row[6] != nil && row[6] != "0"
252254

253255
let column = PluginColumnInfo(
254256
name: columnName,

Plugins/SQLiteDriverPlugin/SQLitePlugin.swift

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -520,7 +520,8 @@ final class SQLitePluginDriver: PluginDatabaseDriver, @unchecked Sendable {
520520
}
521521

522522
let isNullable = row[3] == "0"
523-
let isPrimaryKey = row[5] == "1"
523+
// PRAGMA table_info pk column: 0 = not PK, 1+ = position in composite PK
524+
let isPrimaryKey = row[5] != nil && row[5] != "0"
524525
let defaultValue = row[4]
525526

526527
return PluginColumnInfo(
@@ -554,7 +555,8 @@ final class SQLitePluginDriver: PluginDatabaseDriver, @unchecked Sendable {
554555

555556
let isNullable = row[4] == "0"
556557
let defaultValue = row[5]
557-
let isPrimaryKey = row[6] == "1"
558+
// PRAGMA table_info pk column: 0 = not PK, 1+ = position in composite PK
559+
let isPrimaryKey = row[6] != nil && row[6] != "0"
558560

559561
let column = PluginColumnInfo(
560562
name: columnName,

TablePro/Core/ChangeTracking/DataChangeManager.swift

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,9 @@ final class DataChangeManager {
3232
private(set) var changedRowIndices: Set<Int> = []
3333

3434
var tableName: String = ""
35-
var primaryKeyColumn: String?
35+
var primaryKeyColumns: [String] = []
36+
/// First PK column, for contexts that need a single column (paste, filters)
37+
var primaryKeyColumn: String? { primaryKeyColumns.first }
3638
var databaseType: DatabaseType = .mysql
3739
var pluginDriver: (any PluginDatabaseDriver)?
3840

@@ -141,13 +143,13 @@ final class DataChangeManager {
141143
func configureForTable(
142144
tableName: String,
143145
columns: [String],
144-
primaryKeyColumn: String?,
146+
primaryKeyColumns: [String],
145147
databaseType: DatabaseType = .mysql,
146148
triggerReload: Bool = true
147149
) {
148150
self.tableName = tableName
149151
self.columns = columns
150-
self.primaryKeyColumn = primaryKeyColumn
152+
self.primaryKeyColumns = primaryKeyColumns
151153
self.databaseType = databaseType
152154

153155
changeIndex.removeAll()
@@ -847,7 +849,7 @@ final class DataChangeManager {
847849
let generator = SQLStatementGenerator(
848850
tableName: tableName,
849851
columns: columns,
850-
primaryKeyColumn: primaryKeyColumn,
852+
primaryKeyColumns: primaryKeyColumns,
851853
databaseType: databaseType,
852854
dialect: PluginManager.shared.sqlDialect(for: databaseType),
853855
quoteIdentifier: pluginDriver?.quoteIdentifier
@@ -909,6 +911,7 @@ final class DataChangeManager {
909911
insertedRowIndices.removeAll()
910912
modifiedCells.removeAll()
911913
insertedRowData.removeAll()
914+
changedRowIndices.removeAll()
912915
hasChanges = false
913916
reloadVersion += 1
914917
}
@@ -922,15 +925,15 @@ final class DataChangeManager {
922925
state.insertedRowIndices = insertedRowIndices
923926
state.modifiedCells = modifiedCells
924927
state.insertedRowData = insertedRowData
925-
state.primaryKeyColumn = primaryKeyColumn
928+
state.primaryKeyColumns = primaryKeyColumns
926929
state.columns = columns
927930
return state
928931
}
929932

930933
func restoreState(from state: TabPendingChanges, tableName: String, databaseType: DatabaseType) {
931934
self.tableName = tableName
932935
self.columns = state.columns
933-
self.primaryKeyColumn = state.primaryKeyColumn
936+
self.primaryKeyColumns = state.primaryKeyColumns
934937
self.databaseType = databaseType
935938
self.changes = state.changes
936939
self.deletedRowIndices = state.deletedRowIndices

TablePro/Core/ChangeTracking/SQLStatementGenerator.swift

Lines changed: 52 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -22,23 +22,23 @@ struct SQLStatementGenerator {
2222

2323
let tableName: String
2424
let columns: [String]
25-
let primaryKeyColumn: String?
25+
let primaryKeyColumns: [String]
2626
let databaseType: DatabaseType
2727
let parameterStyle: ParameterStyle
2828
private let quoteIdentifierFn: (String) -> String
2929

3030
init(
3131
tableName: String,
3232
columns: [String],
33-
primaryKeyColumn: String?,
33+
primaryKeyColumns: [String],
3434
databaseType: DatabaseType,
3535
parameterStyle: ParameterStyle? = nil,
3636
dialect: SQLDialectDescriptor? = nil,
3737
quoteIdentifier: ((String) -> String)? = nil
3838
) {
3939
self.tableName = tableName
4040
self.columns = columns
41-
self.primaryKeyColumn = primaryKeyColumn
41+
self.primaryKeyColumns = primaryKeyColumns
4242
self.databaseType = databaseType
4343
self.parameterStyle = parameterStyle ?? Self.defaultParameterStyle(for: databaseType)
4444
self.quoteIdentifierFn = quoteIdentifier ?? quoteIdentifierFromDialect(dialect)
@@ -250,27 +250,35 @@ struct SQLStatementGenerator {
250250
}
251251
}.joined(separator: ", ")
252252

253-
if let pkColumn = primaryKeyColumn,
254-
let pkColumnIndex = columns.firstIndex(of: pkColumn)
255-
{
256-
var pkValue: Any?
257-
if let originalRow = change.originalRow, pkColumnIndex < originalRow.count {
258-
pkValue = originalRow[pkColumnIndex]
259-
} else if let pkChange = change.cellChanges.first(where: { $0.columnName == pkColumn })
260-
{
261-
pkValue = pkChange.oldValue
262-
}
253+
if !primaryKeyColumns.isEmpty {
254+
var conditions: [String] = []
263255

264-
guard pkValue != nil else {
265-
Self.logger.warning(
266-
"Skipping UPDATE for table '\(self.tableName)' - cannot determine primary key value for row"
256+
for pkColumn in primaryKeyColumns {
257+
guard let pkColumnIndex = columns.firstIndex(of: pkColumn) else { return nil }
258+
259+
var pkValue: Any?
260+
if let originalRow = change.originalRow, pkColumnIndex < originalRow.count {
261+
pkValue = originalRow[pkColumnIndex]
262+
} else if let pkChange = change.cellChanges.first(where: { $0.columnName == pkColumn }) {
263+
pkValue = pkChange.oldValue
264+
}
265+
266+
guard pkValue != nil else {
267+
Self.logger.warning(
268+
"Skipping UPDATE for table '\(self.tableName)' - cannot determine value for PK column '\(pkColumn)'"
269+
)
270+
return nil
271+
}
272+
273+
parameters.append(pkValue)
274+
conditions.append(
275+
"\(quoteIdentifierFn(pkColumn)) = \(placeholder(at: parameters.count - 1))"
267276
)
268-
return nil
269277
}
270278

271-
parameters.append(pkValue)
272-
let whereClause =
273-
"\(quoteIdentifierFn(pkColumn)) = \(placeholder(at: parameters.count - 1))"
279+
guard !conditions.isEmpty else { return nil }
280+
281+
let whereClause = conditions.joined(separator: " AND ")
274282
let sql =
275283
"UPDATE \(quoteIdentifierFn(tableName)) SET \(setClauses) WHERE \(whereClause)"
276284
return ParameterizedStatement(sql: sql, parameters: parameters)
@@ -311,27 +319,35 @@ struct SQLStatementGenerator {
311319
private func generateBatchDeleteSQL(for changes: [RowChange]) -> ParameterizedStatement? {
312320
guard !changes.isEmpty else { return nil }
313321

314-
// If we have a primary key, use it for efficient deletion
315-
if let pkColumn = primaryKeyColumn,
316-
let pkIndex = columns.firstIndex(of: pkColumn)
317-
{
318-
// Build OR conditions for all rows using PK
322+
// If we have primary key(s), use them for efficient deletion
323+
if !primaryKeyColumns.isEmpty {
324+
let pkIndices: [(column: String, index: Int)] = primaryKeyColumns.compactMap { col in
325+
guard let idx = columns.firstIndex(of: col) else { return nil }
326+
return (col, idx)
327+
}
328+
guard !pkIndices.isEmpty else { return nil }
329+
319330
var parameters: [Any?] = []
320-
let conditions = changes.compactMap { change -> String? in
321-
guard let originalRow = change.originalRow,
322-
pkIndex < originalRow.count
323-
else {
324-
return nil
331+
let rowConditions = changes.compactMap { change -> String? in
332+
guard let originalRow = change.originalRow else { return nil }
333+
334+
var pkConditions: [String] = []
335+
for pk in pkIndices {
336+
guard pk.index < originalRow.count else { return nil }
337+
parameters.append(originalRow[pk.index])
338+
pkConditions.append(
339+
"\(quoteIdentifierFn(pk.column)) = \(placeholder(at: parameters.count - 1))"
340+
)
325341
}
326-
327-
parameters.append(originalRow[pkIndex])
328-
return
329-
"\(quoteIdentifierFn(pkColumn)) = \(placeholder(at: parameters.count - 1))"
342+
// Single PK: "id = $1", composite: "(order_id = $1 AND product_id = $2)"
343+
return pkIndices.count > 1
344+
? "(\(pkConditions.joined(separator: " AND ")))"
345+
: pkConditions.joined()
330346
}
331347

332-
guard !conditions.isEmpty else { return nil }
348+
guard !rowConditions.isEmpty else { return nil }
333349

334-
let whereClause = conditions.joined(separator: " OR ")
350+
let whereClause = rowConditions.joined(separator: " OR ")
335351
let sql = "DELETE FROM \(quoteIdentifierFn(tableName)) WHERE \(whereClause)"
336352

337353
return ParameterizedStatement(sql: sql, parameters: parameters)

TablePro/Core/Services/Query/RowOperationsManager.swift

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -75,9 +75,10 @@ final class RowOperationsManager {
7575

7676
var newValues = resultRows[sourceRowIndex]
7777

78-
if let pkColumn = changeManager.primaryKeyColumn,
79-
let pkIndex = columns.firstIndex(of: pkColumn) {
80-
newValues[pkIndex] = "__DEFAULT__"
78+
for pkColumn in changeManager.primaryKeyColumns {
79+
if let pkIndex = columns.firstIndex(of: pkColumn) {
80+
newValues[pkIndex] = "__DEFAULT__"
81+
}
8182
}
8283

8384
let newRowIndex = resultRows.count
@@ -311,15 +312,15 @@ final class RowOperationsManager {
311312
/// Paste rows from clipboard (TSV format) and insert into table
312313
/// - Parameters:
313314
/// - columns: Column names for the table
314-
/// - primaryKeyColumn: Primary key column name (will be set to __DEFAULT__)
315+
/// - primaryKeyColumns: Primary key column names (will be set to __DEFAULT__)
315316
/// - resultRows: Current rows (will be mutated)
316317
/// - clipboard: Clipboard provider (injectable for testing)
317318
/// - parser: Row data parser (injectable for testing)
318319
/// - Returns: Array of (rowIndex, values) for pasted rows, or empty array on failure
319320
@MainActor
320321
func pasteRowsFromClipboard(
321322
columns: [String],
322-
primaryKeyColumn: String?,
323+
primaryKeyColumns: [String],
323324
resultRows: inout [[String?]],
324325
clipboard: ClipboardProvider? = nil,
325326
parser: RowDataParser? = nil
@@ -333,7 +334,7 @@ final class RowOperationsManager {
333334
// Create schema
334335
let schema = TableSchema(
335336
columns: columns,
336-
primaryKeyColumn: primaryKeyColumn
337+
primaryKeyColumns: primaryKeyColumns
337338
)
338339

339340
// Parse clipboard text (auto-detect CSV vs TSV)

TablePro/Models/Database/TableSchema.swift

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,20 +12,26 @@ struct TableSchema {
1212
/// Column names in order
1313
let columns: [String]
1414

15-
/// Primary key column name (if exists)
16-
let primaryKeyColumn: String?
15+
/// Primary key column names (empty if no PK). Supports composite keys.
16+
let primaryKeyColumns: [String]
17+
18+
/// First primary key column name, for UI contexts that need a single column
19+
/// (e.g., default filter column, ORDER BY).
20+
var primaryKeyColumn: String? { primaryKeyColumns.first }
1721

1822
/// Number of columns
1923
var columnCount: Int {
2024
columns.count
2125
}
2226

23-
/// Get index of primary key column
24-
var primaryKeyIndex: Int? {
25-
guard let pkColumn = primaryKeyColumn else { return nil }
26-
return columns.firstIndex(of: pkColumn)
27+
/// Get indices of all primary key columns
28+
var primaryKeyIndices: [Int] {
29+
primaryKeyColumns.compactMap { columns.firstIndex(of: $0) }
2730
}
2831

32+
/// Get index of first primary key column
33+
var primaryKeyIndex: Int? { primaryKeyIndices.first }
34+
2935
/// Check if a column name exists
3036
func hasColumn(_ name: String) -> Bool {
3137
columns.contains(name)

TablePro/Models/Query/QueryTab.swift

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,9 @@ struct QueryTab: Identifiable, Equatable {
6666

6767
// Editing support
6868
var tableName: String?
69-
var primaryKeyColumn: String? // Detected PK from schema (set by Phase 2 metadata)
69+
var primaryKeyColumns: [String] = [] // Detected PKs from schema (set by Phase 2 metadata)
70+
/// First PK column, for UI contexts that need a single column (filters, ORDER BY)
71+
var primaryKeyColumn: String? { primaryKeyColumns.first }
7072
var isEditable: Bool
7173
var isView: Bool // True for database views (read-only)
7274
var databaseName: String // Database this tab was opened in (for multi-database restore)
@@ -158,7 +160,7 @@ struct QueryTab: Identifiable, Equatable {
158160
self.errorMessage = nil
159161
self.isExecuting = false
160162
self.tableName = tableName
161-
self.primaryKeyColumn = nil
163+
self.primaryKeyColumns = []
162164
self.isEditable = tabType == .table
163165
self.isView = false
164166
self.databaseName = ""
@@ -185,7 +187,7 @@ struct QueryTab: Identifiable, Equatable {
185187
self.query = persisted.query
186188
self.tabType = persisted.tabType
187189
self.tableName = persisted.tableName
188-
self.primaryKeyColumn = nil
190+
self.primaryKeyColumns = []
189191

190192
// Initialize runtime state with defaults
191193
self.lastExecutedAt = nil

TablePro/Models/Query/QueryTabState.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ struct TabPendingChanges: Equatable {
3535
var insertedRowIndices: Set<Int>
3636
var modifiedCells: [Int: Set<Int>]
3737
var insertedRowData: [Int: [String?]] // Lazy storage for inserted row values
38-
var primaryKeyColumn: String?
38+
var primaryKeyColumns: [String]
3939
var columns: [String]
4040

4141
init() {
@@ -44,7 +44,7 @@ struct TabPendingChanges: Equatable {
4444
self.insertedRowIndices = []
4545
self.modifiedCells = [:]
4646
self.insertedRowData = [:]
47-
self.primaryKeyColumn = nil
47+
self.primaryKeyColumns = []
4848
self.columns = []
4949
}
5050

TablePro/Views/Main/Child/MainEditorContentView.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -496,7 +496,7 @@ struct MainEditorContentView: View {
496496
connectionId: connection.id,
497497
databaseType: connection.type,
498498
tableName: tab.tableName,
499-
primaryKeyColumn: changeManager.primaryKeyColumn,
499+
primaryKeyColumns: changeManager.primaryKeyColumns,
500500
tabType: tab.tabType,
501501
showRowNumbers: AppSettingsManager.shared.dataGrid.showRowNumbers,
502502
hiddenColumns: columnVisibilityManager.hiddenColumns,

0 commit comments

Comments
 (0)