From 08caf60faee0161bd14f6be0a0ab377083734c00 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 21 May 2026 18:57:07 +0700 Subject: [PATCH 1/2] fix(datagrid): copy only visible columns in their current order (#1354) --- .gitignore | 3 + CHANGELOG.md | 1 + .../Coordinators/RowEditingCoordinator.swift | 15 +++-- .../Services/Query/RowOperationsManager.swift | 15 +++-- .../Query/VisibleColumnProjection.swift | 27 ++++++++ .../Views/Results/DataGridCoordinator.swift | 7 ++ .../Results/DataGridView+RowActions.swift | 55 +++++++++++----- .../RowOperationsManagerCopyTests.swift | 65 ++++++++++++++++++- .../Models/VisibleColumnProjectionTests.swift | 51 +++++++++++++++ docs/features/data-grid.mdx | 2 + 10 files changed, 209 insertions(+), 32 deletions(-) create mode 100644 TablePro/Models/Query/VisibleColumnProjection.swift create mode 100644 TableProTests/Models/VisibleColumnProjectionTests.swift diff --git a/.gitignore b/.gitignore index 2c7b0f3e8..2c9051166 100644 --- a/.gitignore +++ b/.gitignore @@ -151,3 +151,6 @@ Libs/.downloaded Libs/dylibs/ Libs/ios/ fix-1322-plugin-abi-and-registry-overhaul.diff + +# Issue analysis blueprints (local only) +.analysis/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fbd84d50..6fb1e6610 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 - Cancelling a pending connection no longer lets the abandoned attempt overwrite or drop a later successful connection to the same database (#1358) - Cancelling a pending SSH connection now closes its tunnel instead of leaving the local forward port open (#1369) - Importing connections from DBeaver now brings over the username (#1355) +- Copying rows now includes only the visible columns, in their current order, instead of every column (#1354) ## [0.43.1] - 2026-05-20 diff --git a/TablePro/Core/Coordinators/RowEditingCoordinator.swift b/TablePro/Core/Coordinators/RowEditingCoordinator.swift index 171fb0bff..8ab37d1bc 100644 --- a/TablePro/Core/Coordinators/RowEditingCoordinator.swift +++ b/TablePro/Core/Coordinators/RowEditingCoordinator.swift @@ -169,7 +169,8 @@ final class RowEditingCoordinator { let tableRows = parent.tabSessionRegistry.tableRows(for: tab.id) parent.rowOperationsManager.copySelectedRowsToClipboard( selectedIndices: indices, - tableRows: tableRows + tableRows: tableRows, + visibleColumnIndices: parent.dataTabDelegate?.tableViewCoordinator?.visibleColumnDataIndices() ) } @@ -179,21 +180,25 @@ final class RowEditingCoordinator { parent.rowOperationsManager.copySelectedRowsToClipboard( selectedIndices: indices, tableRows: tableRows, - includeHeaders: true + includeHeaders: true, + visibleColumnIndices: parent.dataTabDelegate?.tableViewCoordinator?.visibleColumnDataIndices() ) } func copySelectedRowsAsJson(indices: Set) { guard let (tab, _) = parent.tabManager.selectedTabAndIndex, !indices.isEmpty else { return } let tableRows = parent.tabSessionRegistry.tableRows(for: tab.id) + let projection = VisibleColumnProjection( + indices: parent.dataTabDelegate?.tableViewCoordinator?.visibleColumnDataIndices() + ) let rows = indices.sorted().compactMap { idx -> [PluginCellValue]? in guard idx >= 0, idx < tableRows.count else { return nil } - return Array(tableRows.rows[idx].values) + return projection.values(Array(tableRows.rows[idx].values)) } guard !rows.isEmpty else { return } let converter = JsonRowConverter( - columns: tableRows.columns, - columnTypes: tableRows.columnTypes + columns: projection.columns(tableRows.columns), + columnTypes: projection.columnTypes(tableRows.columnTypes) ) ClipboardService.shared.writeText(converter.generateJson(rows: rows)) } diff --git a/TablePro/Core/Services/Query/RowOperationsManager.swift b/TablePro/Core/Services/Query/RowOperationsManager.swift index 906741553..1957eb4d4 100644 --- a/TablePro/Core/Services/Query/RowOperationsManager.swift +++ b/TablePro/Core/Services/Query/RowOperationsManager.swift @@ -231,7 +231,8 @@ final class RowOperationsManager { func copySelectedRowsToClipboard( selectedIndices: Set, tableRows: TableRows, - includeHeaders: Bool = false + includeHeaders: Bool = false, + visibleColumnIndices: [Int]? = nil ) { guard !selectedIndices.isEmpty else { return } @@ -247,13 +248,14 @@ final class RowOperationsManager { let indicesToCopy = isTruncated ? Array(sortedIndices.prefix(Self.maxClipboardRows)) : sortedIndices - let columnCount = tableRows.rows.first?.values.count ?? 1 - let estimatedRowLength = columnCount * 12 + let projection = VisibleColumnProjection(indices: visibleColumnIndices) + let columns = projection.columns(tableRows.columns) + let estimatedRowLength = max(columns.count, 1) * 12 var result = "" result.reserveCapacity(indicesToCopy.count * estimatedRowLength) - if includeHeaders, !tableRows.columns.isEmpty { - for (colIdx, col) in tableRows.columns.enumerated() { + if includeHeaders, !columns.isEmpty { + for (colIdx, col) in columns.enumerated() { if colIdx > 0 { result.append("\t") } result.append(col) } @@ -262,7 +264,8 @@ final class RowOperationsManager { for rowIndex in indicesToCopy { guard rowIndex < tableRows.count else { continue } if !result.isEmpty { result.append("\n") } - for (colIdx, cell) in tableRows.rows[rowIndex].values.enumerated() { + let cells = projection.values(Array(tableRows.rows[rowIndex].values)) + for (colIdx, cell) in cells.enumerated() { if colIdx > 0 { result.append("\t") } switch cell { case .null: diff --git a/TablePro/Models/Query/VisibleColumnProjection.swift b/TablePro/Models/Query/VisibleColumnProjection.swift new file mode 100644 index 000000000..ee24d6b28 --- /dev/null +++ b/TablePro/Models/Query/VisibleColumnProjection.swift @@ -0,0 +1,27 @@ +// +// VisibleColumnProjection.swift +// TablePro +// + +import TableProPluginKit + +struct VisibleColumnProjection { + let indices: [Int]? + + static let identity = VisibleColumnProjection(indices: nil) + + func columns(_ all: [String]) -> [String] { + guard let indices else { return all } + return indices.compactMap { all.indices.contains($0) ? all[$0] : nil } + } + + func columnTypes(_ all: [ColumnType]) -> [ColumnType] { + guard let indices else { return all } + return indices.compactMap { all.indices.contains($0) ? all[$0] : nil } + } + + func values(_ all: [PluginCellValue]) -> [PluginCellValue] { + guard let indices else { return all } + return indices.map { all.indices.contains($0) ? all[$0] : .null } + } +} diff --git a/TablePro/Views/Results/DataGridCoordinator.swift b/TablePro/Views/Results/DataGridCoordinator.swift index 5aa49651a..6c1cbef92 100644 --- a/TablePro/Views/Results/DataGridCoordinator.swift +++ b/TablePro/Views/Results/DataGridCoordinator.swift @@ -43,6 +43,13 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData identitySchema.dataIndex(from: identifier) } + func visibleColumnDataIndices() -> [Int]? { + guard let tableView else { return nil } + return tableView.tableColumns + .filter { !$0.isHidden && $0.identifier != ColumnIdentitySchema.rowNumberIdentifier } + .compactMap { dataColumnIndex(from: $0.identifier) } + } + func savedColumnLayout(binding: ColumnLayoutState) -> ColumnLayoutState? { if tabType == .table, let connectionId, diff --git a/TablePro/Views/Results/DataGridView+RowActions.swift b/TablePro/Views/Results/DataGridView+RowActions.swift index 06f0a2f2b..7f1ae53a5 100644 --- a/TablePro/Views/Results/DataGridView+RowActions.swift +++ b/TablePro/Views/Results/DataGridView+RowActions.swift @@ -40,13 +40,14 @@ extension TableViewCoordinator { func copyRows(at indices: Set) { let sortedIndices = indices.sorted() let tableRows = tableRowsProvider() - let columnTypes = tableRows.columnTypes + let projection = VisibleColumnProjection(indices: visibleColumnDataIndices()) + let columnTypes = projection.columnTypes(tableRows.columnTypes) var tsvRows: [String] = [] var htmlRows: [[String]] = [] for index in sortedIndices { guard let values = displayRow(at: index)?.values else { continue } - let formatted = formatRowValues(values: Array(values), columnTypes: columnTypes) + let formatted = formatRowValues(values: projection.values(Array(values)), columnTypes: columnTypes) tsvRows.append(formatted.joined(separator: "\t")) htmlRows.append(formatted) } @@ -59,14 +60,15 @@ extension TableViewCoordinator { func copyRowsWithHeaders(at indices: Set) { let sortedIndices = indices.sorted() let tableRows = tableRowsProvider() - let columnTypes = tableRows.columnTypes - let columns = tableRows.columns + let projection = VisibleColumnProjection(indices: visibleColumnDataIndices()) + let columnTypes = projection.columnTypes(tableRows.columnTypes) + let columns = projection.columns(tableRows.columns) var tsvRows: [String] = [columns.joined(separator: "\t")] var htmlRows: [[String]] = [] for index in sortedIndices { guard let values = displayRow(at: index)?.values else { continue } - let formatted = formatRowValues(values: Array(values), columnTypes: columnTypes) + let formatted = formatRowValues(values: projection.values(Array(values)), columnTypes: columnTypes) tsvRows.append(formatted.joined(separator: "\t")) htmlRows.append(formatted) } @@ -110,17 +112,18 @@ extension TableViewCoordinator { func copyRowsAsInsert(at indices: Set) { guard let tableName, let databaseType else { return } let tableRows = tableRowsProvider() + let projection = VisibleColumnProjection(indices: visibleColumnDataIndices()) let driver = resolveDriver() do { let converter = try SQLRowToStatementConverter( tableName: tableName, - columns: tableRows.columns, + columns: projection.columns(tableRows.columns), primaryKeyColumn: primaryKeyColumn, databaseType: databaseType, quoteIdentifier: driver?.quoteIdentifier, escapeStringLiteral: driver?.escapeStringLiteral ) - let typedRows: [[PluginCellValue]] = indices.sorted().compactMap { displayRow(at: $0).map { Array($0.values) } } + let typedRows = indices.sorted().compactMap { displayRow(at: $0).map { projection.values(Array($0.values)) } } guard !typedRows.isEmpty else { return } ClipboardService.shared.writeText(converter.generateInserts(rows: typedRows)) } catch { @@ -131,17 +134,18 @@ extension TableViewCoordinator { func copyRowsAsUpdate(at indices: Set) { guard let tableName, let databaseType else { return } let tableRows = tableRowsProvider() + let projection = VisibleColumnProjection(indices: visibleColumnDataIndices()) let driver = resolveDriver() do { let converter = try SQLRowToStatementConverter( tableName: tableName, - columns: tableRows.columns, + columns: projection.columns(tableRows.columns), primaryKeyColumn: primaryKeyColumn, databaseType: databaseType, quoteIdentifier: driver?.quoteIdentifier, escapeStringLiteral: driver?.escapeStringLiteral ) - let typedRows: [[PluginCellValue]] = indices.sorted().compactMap { displayRow(at: $0).map { Array($0.values) } } + let typedRows = indices.sorted().compactMap { displayRow(at: $0).map { projection.values(Array($0.values)) } } guard !typedRows.isEmpty else { return } ClipboardService.shared.writeText(converter.generateUpdates(rows: typedRows)) } catch { @@ -150,27 +154,38 @@ extension TableViewCoordinator { } func copyRowsAsJson(at indices: Set) { - let rows: [[PluginCellValue]] = indices.sorted().compactMap { displayRow(at: $0).map { Array($0.values) } } + let projection = VisibleColumnProjection(indices: visibleColumnDataIndices()) + let rows = indices.sorted().compactMap { displayRow(at: $0).map { projection.values(Array($0.values)) } } guard !rows.isEmpty else { return } let tableRows = tableRowsProvider() - let columnTypes = tableRows.columnTypes - let converter = JsonRowConverter(columns: tableRows.columns, columnTypes: columnTypes) + let converter = JsonRowConverter( + columns: projection.columns(tableRows.columns), + columnTypes: projection.columnTypes(tableRows.columnTypes) + ) ClipboardService.shared.writeText(converter.generateJson(rows: rows)) } func copyRowsAsCsv(at indices: Set, includeHeaders: Bool) { - let rows: [[PluginCellValue]] = indices.sorted().compactMap { displayRow(at: $0).map { Array($0.values) } } + let projection = VisibleColumnProjection(indices: visibleColumnDataIndices()) + let rows = indices.sorted().compactMap { displayRow(at: $0).map { projection.values(Array($0.values)) } } guard !rows.isEmpty else { return } let tableRows = tableRowsProvider() - let converter = CsvRowConverter(columns: tableRows.columns, columnTypes: tableRows.columnTypes) + let converter = CsvRowConverter( + columns: projection.columns(tableRows.columns), + columnTypes: projection.columnTypes(tableRows.columnTypes) + ) ClipboardService.shared.writeCsv(converter.generateCsv(rows: rows, includeHeaders: includeHeaders)) } func copyRowsAsMarkdown(at indices: Set) { - let rows: [[PluginCellValue]] = indices.sorted().compactMap { displayRow(at: $0).map { Array($0.values) } } + let projection = VisibleColumnProjection(indices: visibleColumnDataIndices()) + let rows = indices.sorted().compactMap { displayRow(at: $0).map { projection.values(Array($0.values)) } } guard !rows.isEmpty else { return } let tableRows = tableRowsProvider() - let converter = MarkdownTableConverter(columns: tableRows.columns, columnTypes: tableRows.columnTypes) + let converter = MarkdownTableConverter( + columns: projection.columns(tableRows.columns), + columnTypes: projection.columnTypes(tableRows.columnTypes) + ) ClipboardService.shared.writeText(converter.generateMarkdown(rows: rows)) } @@ -241,10 +256,14 @@ extension TableViewCoordinator { if let values = displayRow(at: row)?.values { let tableRows = tableRowsProvider() - let formatted = formatRowValues(values: Array(values), columnTypes: tableRows.columnTypes) + let projection = VisibleColumnProjection(indices: visibleColumnDataIndices()) + let formatted = formatRowValues( + values: projection.values(Array(values)), + columnTypes: projection.columnTypes(tableRows.columnTypes) + ) item.setString(formatted.joined(separator: "\t"), forType: .string) item.setString( - HtmlTableEncoder.encode(rows: [formatted], headers: tableRows.columns), + HtmlTableEncoder.encode(rows: [formatted], headers: projection.columns(tableRows.columns)), forType: .html ) } diff --git a/TableProTests/Core/Services/RowOperationsManagerCopyTests.swift b/TableProTests/Core/Services/RowOperationsManagerCopyTests.swift index 8c6d9c428..35ff79447 100644 --- a/TableProTests/Core/Services/RowOperationsManagerCopyTests.swift +++ b/TableProTests/Core/Services/RowOperationsManagerCopyTests.swift @@ -1,6 +1,6 @@ import Foundation -import TableProPluginKit @testable import TablePro +import TableProPluginKit import Testing private final class MockClipboardProvider: ClipboardProvider { @@ -57,7 +57,8 @@ struct RowOperationsManagerCopyTests { indices: Set, rows: [[String?]], columns: [String]? = nil, - includeHeaders: Bool = false + includeHeaders: Bool = false, + visibleColumnIndices: [Int]? = nil ) -> String? { let clipboard = MockClipboardProvider() ClipboardService.shared = clipboard @@ -65,7 +66,8 @@ struct RowOperationsManagerCopyTests { manager.copySelectedRowsToClipboard( selectedIndices: indices, tableRows: tableRows, - includeHeaders: includeHeaders + includeHeaders: includeHeaders, + visibleColumnIndices: visibleColumnIndices ) return clipboard.lastWrittenText } @@ -207,4 +209,61 @@ struct RowOperationsManagerCopyTests { #expect(result == "NULL\tNULL\tNULL") } + + @Test("Hidden columns are excluded from copied values") + func hiddenColumnsExcluded() { + let (manager, _) = makeManager() + let rows: [[String?]] = [["1", "Alice", "alice@test.com"]] + + let result = copyAndCapture(manager: manager, indices: [0], rows: rows, visibleColumnIndices: [0, 2]) + + #expect(result == "1\talice@test.com") + } + + @Test("Hidden columns are excluded from headers too") + func hiddenColumnsExcludedFromHeaders() { + let (manager, _) = makeManager() + let rows: [[String?]] = [["1", "Alice", "alice@test.com"]] + + let result = copyAndCapture( + manager: manager, + indices: [0], + rows: rows, + includeHeaders: true, + visibleColumnIndices: [0, 2] + ) + + let lines = result?.components(separatedBy: "\n") ?? [] + #expect(lines.count == 2) + #expect(lines[0] == "id\temail") + #expect(lines[1] == "1\talice@test.com") + } + + @Test("Copy follows visual column order") + func copyFollowsVisualOrder() { + let (manager, _) = makeManager() + let rows: [[String?]] = [["1", "Alice", "alice@test.com"]] + + let result = copyAndCapture( + manager: manager, + indices: [0], + rows: rows, + includeHeaders: true, + visibleColumnIndices: [2, 0, 1] + ) + + let lines = result?.components(separatedBy: "\n") ?? [] + #expect(lines[0] == "email\tid\tname") + #expect(lines[1] == "alice@test.com\t1\tAlice") + } + + @Test("Nil visible indices copies every column unchanged") + func nilIndicesCopiesAllColumns() { + let (manager, _) = makeManager() + let rows: [[String?]] = [["1", "Alice", "alice@test.com"]] + + let result = copyAndCapture(manager: manager, indices: [0], rows: rows, visibleColumnIndices: nil) + + #expect(result == "1\tAlice\talice@test.com") + } } diff --git a/TableProTests/Models/VisibleColumnProjectionTests.swift b/TableProTests/Models/VisibleColumnProjectionTests.swift new file mode 100644 index 000000000..137407452 --- /dev/null +++ b/TableProTests/Models/VisibleColumnProjectionTests.swift @@ -0,0 +1,51 @@ +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@Suite("VisibleColumnProjection") +struct VisibleColumnProjectionTests { + private let columns = ["id", "name", "email"] + private let columnTypes: [ColumnType] = [ + .integer(rawType: "INT"), + .text(rawType: "VARCHAR"), + .text(rawType: "VARCHAR"), + ] + private let values: [PluginCellValue] = [.text("1"), .text("Alice"), .text("alice@test.com")] + + @Test("Nil indices passes columns through unchanged") + func identityColumns() { + let projection = VisibleColumnProjection.identity + #expect(projection.columns(columns) == columns) + #expect(projection.values(values) == values) + } + + @Test("Hidden column dropped from columns, types, and values") + func dropsHiddenColumn() { + let projection = VisibleColumnProjection(indices: [0, 2]) + #expect(projection.columns(columns) == ["id", "email"]) + #expect(projection.columnTypes(columnTypes) == [.integer(rawType: "INT"), .text(rawType: "VARCHAR")]) + #expect(projection.values(values) == [.text("1"), .text("alice@test.com")]) + } + + @Test("Reordered indices reorder columns and values together") + func respectsReorder() { + let projection = VisibleColumnProjection(indices: [2, 0, 1]) + #expect(projection.columns(columns) == ["email", "id", "name"]) + #expect(projection.values(values) == [.text("alice@test.com"), .text("1"), .text("Alice")]) + } + + @Test("Out-of-range value index yields NULL to keep alignment") + func shortRowFillsNull() { + let projection = VisibleColumnProjection(indices: [0, 1, 2]) + let shortRow: [PluginCellValue] = [.text("1")] + #expect(projection.values(shortRow) == [.text("1"), .null, .null]) + } + + @Test("Empty indices produces empty projection") + func emptyIndices() { + let projection = VisibleColumnProjection(indices: []) + #expect(projection.columns(columns).isEmpty) + #expect(projection.values(values).isEmpty) + } +} diff --git a/docs/features/data-grid.mdx b/docs/features/data-grid.mdx index 7fab9d9e9..b7f221d02 100644 --- a/docs/features/data-grid.mdx +++ b/docs/features/data-grid.mdx @@ -339,6 +339,8 @@ Right-click a row and choose **Copy as** for additional formats: Right-click a column header and choose **Copy Column Values** to copy every loaded value in that column, one per line. +Copying rows reflects the grid as shown: hidden columns are left out and the columns follow their current order. + {/* Screenshot: Copy options in context menu */} Date: Thu, 21 May 2026 19:25:04 +0700 Subject: [PATCH 2/2] fix(datagrid): keep the primary key in Copy as UPDATE when its column is hidden (#1354) --- .../Query/VisibleColumnProjection.swift | 5 +++ .../Results/DataGridView+RowActions.swift | 21 ++++++---- .../Models/VisibleColumnProjectionTests.swift | 40 +++++++++++++++++++ docs/features/data-grid.mdx | 2 +- 4 files changed, 59 insertions(+), 9 deletions(-) diff --git a/TablePro/Models/Query/VisibleColumnProjection.swift b/TablePro/Models/Query/VisibleColumnProjection.swift index ee24d6b28..1f89ac37b 100644 --- a/TablePro/Models/Query/VisibleColumnProjection.swift +++ b/TablePro/Models/Query/VisibleColumnProjection.swift @@ -10,6 +10,11 @@ struct VisibleColumnProjection { static let identity = VisibleColumnProjection(indices: nil) + func including(_ index: Int?) -> VisibleColumnProjection { + guard let index, let indices, !indices.contains(index) else { return self } + return VisibleColumnProjection(indices: indices + [index]) + } + func columns(_ all: [String]) -> [String] { guard let indices else { return all } return indices.compactMap { all.indices.contains($0) ? all[$0] : nil } diff --git a/TablePro/Views/Results/DataGridView+RowActions.swift b/TablePro/Views/Results/DataGridView+RowActions.swift index 7f1ae53a5..d9028e737 100644 --- a/TablePro/Views/Results/DataGridView+RowActions.swift +++ b/TablePro/Views/Results/DataGridView+RowActions.swift @@ -40,7 +40,7 @@ extension TableViewCoordinator { func copyRows(at indices: Set) { let sortedIndices = indices.sorted() let tableRows = tableRowsProvider() - let projection = VisibleColumnProjection(indices: visibleColumnDataIndices()) + let projection = visibleColumnProjection let columnTypes = projection.columnTypes(tableRows.columnTypes) var tsvRows: [String] = [] var htmlRows: [[String]] = [] @@ -60,7 +60,7 @@ extension TableViewCoordinator { func copyRowsWithHeaders(at indices: Set) { let sortedIndices = indices.sorted() let tableRows = tableRowsProvider() - let projection = VisibleColumnProjection(indices: visibleColumnDataIndices()) + let projection = visibleColumnProjection let columnTypes = projection.columnTypes(tableRows.columnTypes) let columns = projection.columns(tableRows.columns) var tsvRows: [String] = [columns.joined(separator: "\t")] @@ -112,7 +112,7 @@ extension TableViewCoordinator { func copyRowsAsInsert(at indices: Set) { guard let tableName, let databaseType else { return } let tableRows = tableRowsProvider() - let projection = VisibleColumnProjection(indices: visibleColumnDataIndices()) + let projection = visibleColumnProjection let driver = resolveDriver() do { let converter = try SQLRowToStatementConverter( @@ -134,7 +134,8 @@ extension TableViewCoordinator { func copyRowsAsUpdate(at indices: Set) { guard let tableName, let databaseType else { return } let tableRows = tableRowsProvider() - let projection = VisibleColumnProjection(indices: visibleColumnDataIndices()) + let pkIndex = primaryKeyColumn.flatMap { tableRows.columns.firstIndex(of: $0) } + let projection = visibleColumnProjection.including(pkIndex) let driver = resolveDriver() do { let converter = try SQLRowToStatementConverter( @@ -154,7 +155,7 @@ extension TableViewCoordinator { } func copyRowsAsJson(at indices: Set) { - let projection = VisibleColumnProjection(indices: visibleColumnDataIndices()) + let projection = visibleColumnProjection let rows = indices.sorted().compactMap { displayRow(at: $0).map { projection.values(Array($0.values)) } } guard !rows.isEmpty else { return } let tableRows = tableRowsProvider() @@ -166,7 +167,7 @@ extension TableViewCoordinator { } func copyRowsAsCsv(at indices: Set, includeHeaders: Bool) { - let projection = VisibleColumnProjection(indices: visibleColumnDataIndices()) + let projection = visibleColumnProjection let rows = indices.sorted().compactMap { displayRow(at: $0).map { projection.values(Array($0.values)) } } guard !rows.isEmpty else { return } let tableRows = tableRowsProvider() @@ -178,7 +179,7 @@ extension TableViewCoordinator { } func copyRowsAsMarkdown(at indices: Set) { - let projection = VisibleColumnProjection(indices: visibleColumnDataIndices()) + let projection = visibleColumnProjection let rows = indices.sorted().compactMap { displayRow(at: $0).map { projection.values(Array($0.values)) } } guard !rows.isEmpty else { return } let tableRows = tableRowsProvider() @@ -240,6 +241,10 @@ extension TableViewCoordinator { } } + private var visibleColumnProjection: VisibleColumnProjection { + VisibleColumnProjection(indices: visibleColumnDataIndices()) + } + private func resolveDriver() -> (any DatabaseDriver)? { guard let connectionId else { return nil } return DatabaseManager.shared.driver(for: connectionId) @@ -256,7 +261,7 @@ extension TableViewCoordinator { if let values = displayRow(at: row)?.values { let tableRows = tableRowsProvider() - let projection = VisibleColumnProjection(indices: visibleColumnDataIndices()) + let projection = visibleColumnProjection let formatted = formatRowValues( values: projection.values(Array(values)), columnTypes: projection.columnTypes(tableRows.columnTypes) diff --git a/TableProTests/Models/VisibleColumnProjectionTests.swift b/TableProTests/Models/VisibleColumnProjectionTests.swift index 137407452..6f598b982 100644 --- a/TableProTests/Models/VisibleColumnProjectionTests.swift +++ b/TableProTests/Models/VisibleColumnProjectionTests.swift @@ -48,4 +48,44 @@ struct VisibleColumnProjectionTests { #expect(projection.columns(columns).isEmpty) #expect(projection.values(values).isEmpty) } + + @Test("including(nil) leaves the projection unchanged") + func includingNilIndexUnchanged() { + let projection = VisibleColumnProjection(indices: [0, 2]).including(nil) + #expect(projection.columns(columns) == ["id", "email"]) + } + + @Test("including on the identity projection stays identity") + func includingOnIdentityStaysIdentity() { + #expect(VisibleColumnProjection.identity.including(1).columns(columns) == columns) + } + + @Test("including an already-present index does not duplicate it") + func includingPresentIndexNoDuplicate() { + let projection = VisibleColumnProjection(indices: [0, 2]).including(0) + #expect(projection.columns(columns) == ["id", "email"]) + } + + @Test("including a missing index appends it") + func includingMissingIndexAppends() { + let projection = VisibleColumnProjection(indices: [1, 2]).including(0) + #expect(projection.columns(columns) == ["name", "email", "id"]) + #expect(projection.values(values) == [.text("Alice"), .text("alice@test.com"), .text("1")]) + } + + @Test("UPDATE keeps the primary key in WHERE even when its column is hidden") + @MainActor + func updateRetainsHiddenPrimaryKey() throws { + let projection = VisibleColumnProjection(indices: [1, 2]).including(0) + let dialect = SQLDialectDescriptor(identifierQuote: "`", keywords: [], functions: [], dataTypes: []) + let converter = try SQLRowToStatementConverter( + tableName: "users", + columns: projection.columns(columns), + primaryKeyColumn: "id", + databaseType: .mysql, + dialect: dialect + ) + let result = converter.generateUpdates(rows: [projection.values(values)]) + #expect(result == "UPDATE `users` SET `name` = 'Alice', `email` = 'alice@test.com' WHERE `id` = '1';") + } } diff --git a/docs/features/data-grid.mdx b/docs/features/data-grid.mdx index b7f221d02..ff4ab71e0 100644 --- a/docs/features/data-grid.mdx +++ b/docs/features/data-grid.mdx @@ -339,7 +339,7 @@ Right-click a row and choose **Copy as** for additional formats: Right-click a column header and choose **Copy Column Values** to copy every loaded value in that column, one per line. -Copying rows reflects the grid as shown: hidden columns are left out and the columns follow their current order. +Copying rows reflects the grid as shown: hidden columns are left out and the columns follow their current order. Copy as UPDATE always keeps the primary key so the WHERE clause stays correct, even when that column is hidden. {/* Screenshot: Copy options in context menu */}