From 8584abf3f57d1f3f1f9d05f1f2d7a8142fdf5575 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 21 Jul 2026 18:38:58 +0700 Subject: [PATCH] fix(datagrid): scope Copy as transforms to selected cells (#1929) --- CHANGELOG.md | 1 + .../SQL/SQLRowToStatementConverter.swift | 19 ++- .../Results/DataGridView+RowActions.swift | 23 ++- .../SQLRowToStatementConverterTests.swift | 42 ++++++ .../Results/DataGridRowViewCopyTests.swift | 140 +++++++++++++++++- docs/features/data-grid.mdx | 2 +- 6 files changed, 214 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dd583d783..a7da38a34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/TablePro/Core/Utilities/SQL/SQLRowToStatementConverter.swift b/TablePro/Core/Utilities/SQL/SQLRowToStatementConverter.swift index 1c8b8473c..ccca8d187 100644 --- a/TablePro/Core/Utilities/SQL/SQLRowToStatementConverter.swift +++ b/TablePro/Core/Utilities/SQL/SQLRowToStatementConverter.swift @@ -10,6 +10,7 @@ internal struct SQLRowToStatementConverter { internal let columns: [String] internal let primaryKeyColumn: String? internal let databaseType: DatabaseType + private let settableColumns: Set? private let quoteIdentifierFn: (String) -> String private let escapeStringFn: (String) -> String @@ -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 @@ -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 @@ -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 @@ -84,10 +92,11 @@ 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" @@ -95,10 +104,12 @@ internal struct SQLRowToStatementConverter { 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 diff --git a/TablePro/Views/Results/DataGridView+RowActions.swift b/TablePro/Views/Results/DataGridView+RowActions.swift index 74563f887..6c4f50d1f 100644 --- a/TablePro/Views/Results/DataGridView+RowActions.swift +++ b/TablePro/Views/Results/DataGridView+RowActions.swift @@ -121,7 +121,7 @@ extension TableViewCoordinator { func copyRowsAsInsert(at indices: Set) { guard let tableName, let databaseType else { return } let tableRows = tableRowsProvider() - let projection = visibleColumnProjection + let projection = selectedColumnProjection() let driver = resolveDriver() do { let converter = try SQLRowToStatementConverter( @@ -143,6 +143,9 @@ extension TableViewCoordinator { func copyRowsAsUpdate(at indices: Set) { 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() @@ -152,6 +155,7 @@ extension TableViewCoordinator { columns: projection.columns(tableRows.columns), primaryKeyColumn: primaryKeyColumn, databaseType: databaseType, + settableColumns: settableColumns, quoteIdentifier: driver?.quoteIdentifier, escapeStringLiteral: driver?.escapeStringLiteral ) @@ -164,7 +168,7 @@ extension TableViewCoordinator { } func copyRowsAsJson(at indices: Set) { - 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() @@ -176,7 +180,7 @@ extension TableViewCoordinator { } func copyRowsAsCsv(at indices: Set, 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() @@ -188,7 +192,7 @@ extension TableViewCoordinator { } func copyRowsAsMarkdown(at indices: Set) { - 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() @@ -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) diff --git a/TableProTests/Core/Utilities/SQLRowToStatementConverterTests.swift b/TableProTests/Core/Utilities/SQLRowToStatementConverterTests.swift index d29ceb189..9a32963ab 100644 --- a/TableProTests/Core/Utilities/SQLRowToStatementConverterTests.swift +++ b/TableProTests/Core/Utilities/SQLRowToStatementConverterTests.swift @@ -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") diff --git a/TableProTests/Views/Results/DataGridRowViewCopyTests.swift b/TableProTests/Views/Results/DataGridRowViewCopyTests.swift index 5289e6022..c102c4bf0 100644 --- a/TableProTests/Views/Results/DataGridRowViewCopyTests.swift +++ b/TableProTests/Views/Results/DataGridRowViewCopyTests.swift @@ -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")]], @@ -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) } } diff --git a/docs/features/data-grid.mdx b/docs/features/data-grid.mdx index 490e44080..c2697a592 100644 --- a/docs/features/data-grid.mdx +++ b/docs/features/data-grid.mdx @@ -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