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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- Copy as INSERT, UPDATE, JSON, CSV, or Markdown now includes only the columns you selected when you select specific cells. It used to include every column in the row. Copy as UPDATE still keys its WHERE clause on the primary key. (#1929)
- Windows Authentication (Kerberos) to SQL Server now works on macOS. The bundled SQL Server driver was built without Kerberos support, so every Windows-auth connect failed as if the credentials were wrong. (#1918)
- MySQL and MariaDB queries no longer fail after about a minute when a longer query timeout is set. The client read timeout now follows the configured query timeout, so a long query or stored procedure runs for the full time you allow. (#1921)
- A dropped MySQL or MariaDB connection no longer silently re-runs a statement that changes data, so a lost connection can no longer run an insert, update, or stored procedure twice. (#1921)
Expand Down
19 changes: 15 additions & 4 deletions TablePro/Core/Utilities/SQL/SQLRowToStatementConverter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ internal struct SQLRowToStatementConverter {
internal let columns: [String]
internal let primaryKeyColumn: String?
internal let databaseType: DatabaseType
private let settableColumns: Set<String>?
private let quoteIdentifierFn: (String) -> String
private let escapeStringFn: (String) -> String

Expand All @@ -18,6 +19,7 @@ internal struct SQLRowToStatementConverter {
columns: [String],
primaryKeyColumn: String?,
databaseType: DatabaseType,
settableColumns: [String]? = nil,
dialect: SQLDialectDescriptor? = nil,
quoteIdentifier: ((String) -> String)? = nil,
escapeStringLiteral: ((String) -> String)? = nil
Expand All @@ -26,6 +28,7 @@ internal struct SQLRowToStatementConverter {
self.columns = columns
self.primaryKeyColumn = primaryKeyColumn
self.databaseType = databaseType
self.settableColumns = settableColumns.map(Set.init)

if let quoteIdentifier, let escapeStringLiteral {
self.quoteIdentifierFn = quoteIdentifier
Expand Down Expand Up @@ -67,12 +70,17 @@ internal struct SQLRowToStatementConverter {
internal func generateUpdates(rows: [[PluginCellValue]]) -> String {
let capped = rows.prefix(Self.maxRows)

return capped.map { row in
return capped.compactMap { row in
buildUpdateStatement(row: row)
}.joined(separator: "\n")
}

private func buildUpdateStatement(row: [PluginCellValue]) -> String {
private func isSettable(_ column: String) -> Bool {
guard let settableColumns else { return true }
return settableColumns.contains(column)
}

private func buildUpdateStatement(row: [PluginCellValue]) -> String? {
let quotedTable = quoteColumn(tableName)

let setClause: String
Expand All @@ -84,21 +92,24 @@ internal struct SQLRowToStatementConverter {
let pkValue = row[pkIndex]

let setClauses = columns.enumerated().compactMap { index, col -> String? in
guard col != pkColumn else { return nil }
guard col != pkColumn, isSettable(col) else { return nil }
let value = row.indices.contains(index) ? row[index] : .null
return "\(quoteColumn(col)) = \(formatValue(value))"
}
guard !setClauses.isEmpty else { return nil }
setClause = setClauses.joined(separator: ", ")
if pkValue.isNull {
whereClause = "\(quoteColumn(pkColumn)) IS NULL"
} else {
whereClause = "\(quoteColumn(pkColumn)) = \(formatValue(pkValue))"
}
} else {
let allClauses = columns.enumerated().map { index, col -> String in
let allClauses = columns.enumerated().compactMap { index, col -> String? in
guard isSettable(col) else { return nil }
let value = row.indices.contains(index) ? row[index] : .null
return "\(quoteColumn(col)) = \(formatValue(value))"
}
guard !allClauses.isEmpty else { return nil }
setClause = allClauses.joined(separator: ", ")

let whereParts = columns.enumerated().map { index, col -> String in
Expand Down
23 changes: 19 additions & 4 deletions TablePro/Views/Results/DataGridView+RowActions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ extension TableViewCoordinator {
func copyRowsAsInsert(at indices: Set<Int>) {
guard let tableName, let databaseType else { return }
let tableRows = tableRowsProvider()
let projection = visibleColumnProjection
let projection = selectedColumnProjection()
let driver = resolveDriver()
do {
let converter = try SQLRowToStatementConverter(
Expand All @@ -143,6 +143,9 @@ extension TableViewCoordinator {
func copyRowsAsUpdate(at indices: Set<Int>) {
guard let tableName, let databaseType else { return }
let tableRows = tableRowsProvider()
let settableColumns = selectionController.isEmpty
? nil
: selectedColumnProjection().columns(tableRows.columns)
let pkIndex = primaryKeyColumn.flatMap { tableRows.columns.firstIndex(of: $0) }
let projection = visibleColumnProjection.including(pkIndex)
let driver = resolveDriver()
Expand All @@ -152,6 +155,7 @@ extension TableViewCoordinator {
columns: projection.columns(tableRows.columns),
primaryKeyColumn: primaryKeyColumn,
databaseType: databaseType,
settableColumns: settableColumns,
quoteIdentifier: driver?.quoteIdentifier,
escapeStringLiteral: driver?.escapeStringLiteral
)
Expand All @@ -164,7 +168,7 @@ extension TableViewCoordinator {
}

func copyRowsAsJson(at indices: Set<Int>) {
let projection = visibleColumnProjection
let projection = selectedColumnProjection()
let rows = indices.sorted().compactMap { displayRow(at: $0).map { projection.values(Array($0.values)) } }
guard !rows.isEmpty else { return }
let tableRows = tableRowsProvider()
Expand All @@ -176,7 +180,7 @@ extension TableViewCoordinator {
}

func copyRowsAsCsv(at indices: Set<Int>, includeHeaders: Bool) {
let projection = visibleColumnProjection
let projection = selectedColumnProjection()
let rows = indices.sorted().compactMap { displayRow(at: $0).map { projection.values(Array($0.values)) } }
guard !rows.isEmpty else { return }
let tableRows = tableRowsProvider()
Expand All @@ -188,7 +192,7 @@ extension TableViewCoordinator {
}

func copyRowsAsMarkdown(at indices: Set<Int>) {
let projection = visibleColumnProjection
let projection = selectedColumnProjection()
let rows = indices.sorted().compactMap { displayRow(at: $0).map { projection.values(Array($0.values)) } }
guard !rows.isEmpty else { return }
let tableRows = tableRowsProvider()
Expand Down Expand Up @@ -253,6 +257,17 @@ extension TableViewCoordinator {
VisibleColumnProjection(indices: visibleColumnDataIndices())
}

private func selectedColumnProjection() -> VisibleColumnProjection {
guard !selectionController.isEmpty else { return visibleColumnProjection }
let selectedColumns = selectionController.selection.affectedColumns
guard !selectedColumns.isEmpty else { return visibleColumnProjection }
guard let visible = visibleColumnDataIndices() else {
return VisibleColumnProjection(indices: selectedColumns.sorted())
}
let ordered = visible.filter { selectedColumns.contains($0) }
return ordered.isEmpty ? visibleColumnProjection : VisibleColumnProjection(indices: ordered)
}

private func resolveDriver() -> (any DatabaseDriver)? {
guard let connectionId else { return nil }
return DatabaseManager.shared.driver(for: connectionId)
Expand Down
42 changes: 42 additions & 0 deletions TableProTests/Core/Utilities/SQLRowToStatementConverterTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,48 @@ struct SQLRowToStatementConverterTests {
#expect(result == "UPDATE `users` SET `name` = 'Alice', `email` = 'alice@example.com' WHERE `name` = 'Alice' AND `email` = 'alice@example.com';")
}

@Test("UPDATE restricts SET to settable columns and keys WHERE on the primary key")
func updateSettableColumnsRestrictsSetClause() throws {
let converter = try SQLRowToStatementConverter(
tableName: "users",
columns: ["id", "name", "email"],
primaryKeyColumn: "id",
databaseType: .mysql,
settableColumns: ["email"],
dialect: Self.mysqlDialect
)
let result = converter.generateUpdates(rows: [["1", "Alice", "alice@example.com"]])
#expect(result == "UPDATE `users` SET `email` = 'alice@example.com' WHERE `id` = '1';")
}

@Test("UPDATE without a primary key keeps the full row in WHERE while restricting SET")
func updateSettableColumnsNoPrimaryKeyKeepsFullRowWhere() throws {
let converter = try SQLRowToStatementConverter(
tableName: "users",
columns: ["id", "name", "email"],
primaryKeyColumn: nil,
databaseType: .mysql,
settableColumns: ["email"],
dialect: Self.mysqlDialect
)
let result = converter.generateUpdates(rows: [["1", "Alice", "alice@example.com"]])
#expect(result == "UPDATE `users` SET `email` = 'alice@example.com' WHERE `id` = '1' AND `name` = 'Alice' AND `email` = 'alice@example.com';")
}

@Test("UPDATE emits no statement when only the primary key is settable")
func updateSettableColumnsPrimaryKeyOnlyEmitsNothing() throws {
let converter = try SQLRowToStatementConverter(
tableName: "users",
columns: ["id", "name", "email"],
primaryKeyColumn: "id",
databaseType: .mysql,
settableColumns: ["id"],
dialect: Self.mysqlDialect
)
let result = converter.generateUpdates(rows: [["1", "Alice", "alice@example.com"]])
#expect(result == "")
}

// MARK: - Edge Cases

@Test("Empty rows input returns empty string")
Expand Down
140 changes: 136 additions & 4 deletions TableProTests/Views/Results/DataGridRowViewCopyTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -375,8 +375,8 @@ struct DataGridRowViewCopyTests {
withExtendedLifetime(coordinator) {}
}

@Test("Copy as JSON covers every row of a column selection")
func copyAsJsonUsesColumnSelection() {
@Test("Copy as JSON includes only the selected column")
func copyAsJsonScopesToSelectedColumn() {
withClipboard { clipboard in
let coordinator = makeCoordinator(
rows: [[.text("1"), .text("Alice")], [.text("2"), .text("Bob")]],
Expand All @@ -389,8 +389,140 @@ struct DataGridRowViewCopyTests {

_ = rowView.perform(NSSelectorFromString("copyAsJson"))

#expect(clipboard.text?.contains("Alice") == true)
#expect(clipboard.text?.contains("Bob") == true)
#expect(clipboard.text?.contains("\"c0\"") == true)
#expect(clipboard.text?.contains("Alice") != true)
#expect(clipboard.text?.contains("Bob") != true)
}
}

private func makeCellSelectedCoordinator() -> TableViewCoordinator {
let coordinator = makeCoordinator(
rows: [[.text("1"), .text("Alice"), .text("NYC"), .text("x")]],
columnTypes: [
.integer(rawType: "INT"), .text(rawType: "TEXT"),
.text(rawType: "TEXT"), .text(rawType: "TEXT")
]
)
coordinator.tableName = "users"
coordinator.databaseType = .mysql
coordinator.selectionController.update(
GridSelection(
rectangles: [
GridRect(cell: GridCoord(row: 0, column: 2)),
GridRect(cell: GridCoord(row: 0, column: 3))
],
activeCell: GridCoord(row: 0, column: 3),
anchor: GridCoord(row: 0, column: 2)
)
)
return coordinator
}

@Test("Copy as INSERT includes only the selected columns")
func copyAsInsertScopesToSelectedColumns() {
withClipboard { clipboard in
let coordinator = makeCellSelectedCoordinator()
let rowView = DataGridRowView()
rowView.coordinator = coordinator
rowView.rowIndex = 0

_ = rowView.perform(NSSelectorFromString("copyAsInsert"))

#expect(clipboard.text == "INSERT INTO `users` (`c2`, `c3`) VALUES ('NYC', 'x');")
}
}

@Test("Copy as UPDATE sets only selected columns and keys WHERE on the primary key")
func copyAsUpdateScopesSetToSelectedColumns() {
withClipboard { clipboard in
let coordinator = makeCellSelectedCoordinator()
coordinator.primaryKeyColumns = ["c0"]
let rowView = DataGridRowView()
rowView.coordinator = coordinator
rowView.rowIndex = 0

_ = rowView.perform(NSSelectorFromString("copyAsUpdate"))

#expect(clipboard.text == "UPDATE `users` SET `c2` = 'NYC', `c3` = 'x' WHERE `c0` = '1';")
}
}

@Test("Copy as UPDATE without a primary key keeps the full row in WHERE")
func copyAsUpdateNoPrimaryKeyKeysWhereOnFullRow() {
withClipboard { clipboard in
let coordinator = makeCoordinator(
rows: [[.text("1"), .text("Alice"), .text("NYC")]],
columnTypes: [.integer(rawType: "INT"), .text(rawType: "TEXT"), .text(rawType: "TEXT")]
)
coordinator.tableName = "users"
coordinator.databaseType = .mysql
coordinator.selectionController.update(
GridSelection(
rectangles: [GridRect(cell: GridCoord(row: 0, column: 2))],
activeCell: GridCoord(row: 0, column: 2),
anchor: GridCoord(row: 0, column: 2)
)
)
let rowView = DataGridRowView()
rowView.coordinator = coordinator
rowView.rowIndex = 0

_ = rowView.perform(NSSelectorFromString("copyAsUpdate"))

#expect(clipboard.text == "UPDATE `users` SET `c2` = 'NYC' WHERE `c0` = '1' AND `c1` = 'Alice' AND `c2` = 'NYC';")
}
}

@Test("Copy as INSERT flattens a discontiguous selection to the column-union rectangle")
func copyAsInsertFlattensDiscontiguousSelection() {
withClipboard { clipboard in
let coordinator = makeCoordinator(
rows: [
[.text("1"), .text("Alice"), .text("NYC")],
[.text("2"), .text("Bob"), .text("LA")]
],
columnTypes: [.integer(rawType: "INT"), .text(rawType: "TEXT"), .text(rawType: "TEXT")]
)
coordinator.tableName = "users"
coordinator.databaseType = .mysql
coordinator.selectionController.update(
GridSelection(
rectangles: [
GridRect(cell: GridCoord(row: 0, column: 2)),
GridRect(cell: GridCoord(row: 1, column: 0))
],
activeCell: GridCoord(row: 1, column: 0),
anchor: GridCoord(row: 0, column: 2)
)
)
let rowView = DataGridRowView()
rowView.coordinator = coordinator
rowView.rowIndex = 0

_ = rowView.perform(NSSelectorFromString("copyAsInsert"))

let text = clipboard.text ?? ""
#expect(text.contains("INSERT INTO `users` (`c0`, `c2`) VALUES ('1', 'NYC');"))
#expect(text.contains("INSERT INTO `users` (`c0`, `c2`) VALUES ('2', 'LA');"))
}
}

@Test("Copy as CSV includes only the selected column")
func copyAsCsvScopesToSelectedColumn() {
withClipboard { clipboard in
let coordinator = makeCoordinator(
rows: [[.text("1"), .text("Alice")], [.text("2"), .text("Bob")]],
columnTypes: [.integer(rawType: "INT"), .text(rawType: "TEXT")]
)
coordinator.selectionController.selectEntireColumn(0, totalRows: 2)
let rowView = DataGridRowView()
rowView.coordinator = coordinator
rowView.rowIndex = 0

_ = rowView.perform(NSSelectorFromString("copyAsCsv"))

#expect(clipboard.text?.contains("Alice") != true)
#expect(clipboard.text?.contains("Bob") != true)
}
}

Expand Down
2 changes: 1 addition & 1 deletion docs/features/data-grid.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ Right-click a row and choose **Copy as** for more formats:
| IN Clause | `('a', 'b', 'c')` for `WHERE col IN (...)` |
| INSERT / UPDATE | SQL statements per row (SQL databases) |

Copies follow the grid as shown: hidden columns are left out and columns keep their current order. **Copy as UPDATE** always includes the primary key. Right-click a header and choose **Copy Column Values** to copy a whole column, one value per line.
Copies follow the grid as shown: hidden columns are left out and columns keep their current order. When you select specific cells, **Copy as** (JSON, CSV, Markdown, INSERT, and UPDATE) includes only the columns you selected; select whole rows to include every column. **Copy as UPDATE** still keys its WHERE clause on the primary key, even when the primary key cell is not part of the selection. Right-click a header and choose **Copy Column Values** to copy a whole column, one value per line.

## Pagination and Limits

Expand Down
Loading