Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 4 additions & 2 deletions Plugins/SQLiteDriverPlugin/SQLitePlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand Down
15 changes: 9 additions & 6 deletions TablePro/Core/ChangeTracking/DataChangeManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,9 @@ final class DataChangeManager {
private(set) var changedRowIndices: Set<Int> = []

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)?

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -909,6 +911,7 @@ final class DataChangeManager {
insertedRowIndices.removeAll()
modifiedCells.removeAll()
insertedRowData.removeAll()
changedRowIndices.removeAll()
hasChanges = false
reloadVersion += 1
}
Expand All @@ -922,15 +925,15 @@ final class DataChangeManager {
state.insertedRowIndices = insertedRowIndices
state.modifiedCells = modifiedCells
state.insertedRowData = insertedRowData
state.primaryKeyColumn = primaryKeyColumn
state.primaryKeyColumns = primaryKeyColumns
state.columns = columns
return state
}

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
Expand Down
88 changes: 52 additions & 36 deletions TablePro/Core/ChangeTracking/SQLStatementGenerator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,23 +22,23 @@ 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

init(
tableName: String,
columns: [String],
primaryKeyColumn: String?,
primaryKeyColumns: [String],
databaseType: DatabaseType,
parameterStyle: ParameterStyle? = nil,
dialect: SQLDialectDescriptor? = nil,
quoteIdentifier: ((String) -> String)? = nil
) {
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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
13 changes: 7 additions & 6 deletions TablePro/Core/Services/Query/RowOperationsManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -311,15 +312,15 @@ 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)
/// - Returns: Array of (rowIndex, values) for pasted rows, or empty array on failure
@MainActor
func pasteRowsFromClipboard(
columns: [String],
primaryKeyColumn: String?,
primaryKeyColumns: [String],
resultRows: inout [[String?]],
clipboard: ClipboardProvider? = nil,
parser: RowDataParser? = nil
Expand All @@ -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)
Expand Down
18 changes: 12 additions & 6 deletions TablePro/Models/Database/TableSchema.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
8 changes: 5 additions & 3 deletions TablePro/Models/Query/QueryTab.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 = ""
Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions TablePro/Models/Query/QueryTabState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ struct TabPendingChanges: Equatable {
var insertedRowIndices: Set<Int>
var modifiedCells: [Int: Set<Int>]
var insertedRowData: [Int: [String?]] // Lazy storage for inserted row values
var primaryKeyColumn: String?
var primaryKeyColumns: [String]
var columns: [String]

init() {
Expand All @@ -44,7 +44,7 @@ struct TabPendingChanges: Equatable {
self.insertedRowIndices = []
self.modifiedCells = [:]
self.insertedRowData = [:]
self.primaryKeyColumn = nil
self.primaryKeyColumns = []
self.columns = []
}

Expand Down
2 changes: 1 addition & 1 deletion TablePro/Views/Main/Child/MainEditorContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading