From f65c95e148fd7f04947d1c0533ffb4e07b2b21b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Tue, 19 May 2026 12:31:39 +0700 Subject: [PATCH 1/2] feat(datagrid): copy column values plus Copy as CSV/Markdown/IN Clause (#1325) --- CHANGELOG.md | 5 + .../Infrastructure/ClipboardService.swift | 13 ++- .../Core/Utilities/SQL/CsvRowConverter.swift | 67 +++++++++++ .../Utilities/SQL/InClauseConverter.swift | 66 +++++++++++ .../SQL/MarkdownTableConverter.swift | 81 ++++++++++++++ TablePro/Resources/Localizable.xcstrings | 15 +++ TablePro/Views/Results/DataGridRowView.swift | 86 ++++++++++---- .../Results/DataGridView+RowActions.swift | 60 +++++++++- .../Extensions/DataGridView+Sort.swift | 16 +++ .../RowOperationsManagerCopyTests.swift | 5 + .../Core/Utilities/CsvRowConverterTests.swift | 102 +++++++++++++++++ .../Utilities/InClauseConverterTests.swift | 105 ++++++++++++++++++ .../MarkdownTableConverterTests.swift | 61 ++++++++++ .../Extensions/CellPasteRoutingTests.swift | 1 + docs/features/data-grid.mdx | 18 ++- 15 files changed, 672 insertions(+), 29 deletions(-) create mode 100644 TablePro/Core/Utilities/SQL/CsvRowConverter.swift create mode 100644 TablePro/Core/Utilities/SQL/InClauseConverter.swift create mode 100644 TablePro/Core/Utilities/SQL/MarkdownTableConverter.swift create mode 100644 TableProTests/Core/Utilities/CsvRowConverterTests.swift create mode 100644 TableProTests/Core/Utilities/InClauseConverterTests.swift create mode 100644 TableProTests/Core/Utilities/MarkdownTableConverterTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 6be19ff9d..21128e6b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Right-click a column header to copy all its values from the loaded rows (#1325) +- Copy as submenu on the row context menu now offers CSV, CSV with Headers, Markdown table, and IN Clause for SQL `WHERE id IN (...)` lookups (#1325) + ### Fixed - DuckDB Spatial `GEOMETRY` columns render as WKT, not NULL (#1324) diff --git a/TablePro/Core/Services/Infrastructure/ClipboardService.swift b/TablePro/Core/Services/Infrastructure/ClipboardService.swift index 66cf2e82d..2a82bc0a2 100644 --- a/TablePro/Core/Services/Infrastructure/ClipboardService.swift +++ b/TablePro/Core/Services/Infrastructure/ClipboardService.swift @@ -2,9 +2,6 @@ // ClipboardService.swift // TablePro // -// Abstraction over clipboard operations for testability. -// Provides protocol-based access to pasteboard data. -// import AppKit import UniformTypeIdentifiers @@ -12,6 +9,7 @@ import UniformTypeIdentifiers protocol ClipboardProvider { func readText() -> String? func writeText(_ text: String) + func writeCsv(_ csv: String) func writeRows(tsv: String, html: String?) var hasText: Bool { get } var hasGridRows: Bool { get } @@ -19,6 +17,7 @@ protocol ClipboardProvider { struct NSPasteboardClipboardProvider: ClipboardProvider { private static let tsvType = NSPasteboard.PasteboardType("public.utf8-tab-separated-values-text") + private static let csvType = NSPasteboard.PasteboardType("public.comma-separated-values-text") private static let gridRowsType = NSPasteboard.PasteboardType("com.TablePro.gridRows") func readText() -> String? { @@ -32,6 +31,14 @@ struct NSPasteboardClipboardProvider: ClipboardProvider { pb.setString(text, forType: NSPasteboard.PasteboardType(UTType.utf8PlainText.identifier)) } + func writeCsv(_ csv: String) { + let pb = NSPasteboard.general + pb.clearContents() + pb.setString(csv, forType: .string) + pb.setString(csv, forType: NSPasteboard.PasteboardType(UTType.utf8PlainText.identifier)) + pb.setString(csv, forType: Self.csvType) + } + func writeRows(tsv: String, html: String?) { let pb = NSPasteboard.general pb.clearContents() diff --git a/TablePro/Core/Utilities/SQL/CsvRowConverter.swift b/TablePro/Core/Utilities/SQL/CsvRowConverter.swift new file mode 100644 index 000000000..1c123a38f --- /dev/null +++ b/TablePro/Core/Utilities/SQL/CsvRowConverter.swift @@ -0,0 +1,67 @@ +// +// CsvRowConverter.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +internal struct CsvRowConverter { + internal let columns: [String] + internal let columnTypes: [ColumnType] + + private static let maxRows = 50_000 + + func generateCsv(rows: [[PluginCellValue]], includeHeaders: Bool) -> String { + let cappedRows = rows.prefix(Self.maxRows) + let columnCount = columns.count + + var result = String() + result.reserveCapacity(cappedRows.count * columnCount * 16) + + if includeHeaders { + for (idx, header) in columns.enumerated() { + if idx > 0 { result.append(",") } + result.append(Self.encode(header)) + } + result.append("\n") + } + + for row in cappedRows { + for idx in 0.. 0 { result.append(",") } + guard row.indices.contains(idx) else { continue } + + let cell = row[idx] + switch cell { + case .null: + continue + case .text(let value): + let columnType = columnTypes.indices.contains(idx) ? columnTypes[idx] : nil + let formatted = (columnType?.isBlobType ?? false) + ? (value.formattedAsCompactHex() ?? value) + : value + result.append(Self.encode(formatted)) + case .bytes(let data): + let blob = String(data: data, encoding: .isoLatin1)?.formattedAsCompactHex() ?? "" + result.append(Self.encode(blob)) + } + } + result.append("\n") + } + + return result + } + + static func encode(_ value: String) -> String { + let needsQuoting = value.contains(",") + || value.contains("\"") + || value.contains("\n") + || value.contains("\r") + || value.hasPrefix(" ") + || value.hasSuffix(" ") + guard needsQuoting else { return value } + let escaped = value.replacingOccurrences(of: "\"", with: "\"\"") + return "\"\(escaped)\"" + } +} diff --git a/TablePro/Core/Utilities/SQL/InClauseConverter.swift b/TablePro/Core/Utilities/SQL/InClauseConverter.swift new file mode 100644 index 000000000..a12e3be09 --- /dev/null +++ b/TablePro/Core/Utilities/SQL/InClauseConverter.swift @@ -0,0 +1,66 @@ +// +// InClauseConverter.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +internal struct InClauseConverter { + internal let columnIndex: Int + internal let columnTypes: [ColumnType] + internal let escapeStringLiteral: ((String) -> String)? + + private static let maxRows = 50_000 + + func generateInClause(rows: [[PluginCellValue]]) -> String { + let cappedRows = rows.prefix(Self.maxRows) + let columnType: ColumnType = columnTypes.indices.contains(columnIndex) + ? columnTypes[columnIndex] + : .text(rawType: nil) + + let values: [String] = cappedRows.compactMap { row in + guard row.indices.contains(columnIndex) else { return nil } + return format(cell: row[columnIndex], type: columnType) + } + + guard !values.isEmpty else { return "()" } + return "(\(values.joined(separator: ", ")))" + } + + private func format(cell: PluginCellValue, type: ColumnType) -> String? { + switch cell { + case .null, .bytes: + return nil + case .text(let value): + return formatScalar(value, type: type) + } + } + + private func formatScalar(_ value: String, type: ColumnType) -> String { + switch type { + case .integer: + if let intVal = Int64(value) { return String(intVal) } + return quoted(value) + case .decimal: + if Double(value) != nil { return value } + return quoted(value) + case .boolean: + switch value.lowercased() { + case "true", "1", "yes", "on": + return "TRUE" + case "false", "0", "no", "off": + return "FALSE" + default: + return quoted(value) + } + case .blob, .text, .date, .timestamp, .datetime, .json, .enumType, .set, .spatial: + return quoted(value) + } + } + + private func quoted(_ value: String) -> String { + let escaped = escapeStringLiteral?(value) ?? value.replacingOccurrences(of: "'", with: "''") + return "'\(escaped)'" + } +} diff --git a/TablePro/Core/Utilities/SQL/MarkdownTableConverter.swift b/TablePro/Core/Utilities/SQL/MarkdownTableConverter.swift new file mode 100644 index 000000000..f8c14c684 --- /dev/null +++ b/TablePro/Core/Utilities/SQL/MarkdownTableConverter.swift @@ -0,0 +1,81 @@ +// +// MarkdownTableConverter.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +internal struct MarkdownTableConverter { + internal let columns: [String] + internal let columnTypes: [ColumnType] + + private static let maxRows = 50_000 + + func generateMarkdown(rows: [[PluginCellValue]]) -> String { + let cappedRows = rows.prefix(Self.maxRows) + let columnCount = columns.count + guard columnCount > 0 else { return "" } + + var result = String() + result.reserveCapacity(cappedRows.count * columnCount * 16) + + result.append("| ") + result.append(columns.map(Self.encode).joined(separator: " | ")) + result.append(" |\n") + + result.append("|") + for _ in 0.. 0 { result.append(" | ") } + + guard row.indices.contains(idx) else { + result.append("NULL") + continue + } + + let cell = row[idx] + switch cell { + case .null: + result.append("NULL") + case .text(let value): + let columnType = columnTypes.indices.contains(idx) ? columnTypes[idx] : nil + let formatted = (columnType?.isBlobType ?? false) + ? (value.formattedAsCompactHex() ?? value) + : value + result.append(Self.encode(formatted)) + case .bytes(let data): + let blob = String(data: data, encoding: .isoLatin1)?.formattedAsCompactHex() ?? "" + result.append(Self.encode(blob)) + } + } + result.append(" |\n") + } + + return result + } + + static func encode(_ value: String) -> String { + var result = String() + result.reserveCapacity((value as NSString).length) + + for scalar in value.unicodeScalars { + switch scalar { + case "|": + result.append("\\|") + case "\n", "\r": + result.append("
") + default: + result.append(Character(scalar)) + } + } + + return result + } +} diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 3aa63c11a..1671f91ea 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -12254,6 +12254,9 @@ } } } + }, + "Copy Column Values" : { + }, "Copy Connection ID" : { @@ -13443,6 +13446,12 @@ } } } + }, + "CSV" : { + + }, + "CSV with Headers" : { + }, "CURDATE()" : { "extractionState" : "stale", @@ -24238,6 +24247,9 @@ } } } + }, + "IN Clause" : { + }, "in list" : { "localizations" : { @@ -27986,6 +27998,9 @@ } } } + }, + "Markdown" : { + }, "Massive" : { "extractionState" : "stale", diff --git a/TablePro/Views/Results/DataGridRowView.swift b/TablePro/Views/Results/DataGridRowView.swift index e66afb973..9f49797bf 100644 --- a/TablePro/Views/Results/DataGridRowView.swift +++ b/TablePro/Views/Results/DataGridRowView.swift @@ -106,7 +106,7 @@ class DataGridRowView: NSTableRowView { let locationInTable = tableView.convert(locationInRow, from: self) let clickedColumn = tableView.column(at: locationInTable) - let dataColumnIndex: Int = clickedColumn > 0 + let dataColumnIndex: Int = clickedColumn >= 0 ? DataGridView.dataColumnIndex(for: clickedColumn, in: tableView, schema: coordinator.identitySchema) ?? -1 : -1 @@ -149,6 +149,37 @@ class DataGridRowView: NSTableRowView { jsonItem.target = self copyAsMenu.addItem(jsonItem) + let csvItem = NSMenuItem( + title: String(localized: "CSV"), + action: #selector(copyAsCsv), + keyEquivalent: "") + csvItem.target = self + copyAsMenu.addItem(csvItem) + + let csvHeadersItem = NSMenuItem( + title: String(localized: "CSV with Headers"), + action: #selector(copyAsCsvWithHeaders), + keyEquivalent: "") + csvHeadersItem.target = self + copyAsMenu.addItem(csvHeadersItem) + + let markdownItem = NSMenuItem( + title: String(localized: "Markdown"), + action: #selector(copyAsMarkdown), + keyEquivalent: "") + markdownItem.target = self + copyAsMenu.addItem(markdownItem) + + if dataColumnIndex >= 0 { + let inClauseItem = NSMenuItem( + title: String(localized: "IN Clause"), + action: #selector(copyAsInClause(_:)), + keyEquivalent: "") + inClauseItem.representedObject = dataColumnIndex + inClauseItem.target = self + copyAsMenu.addItem(inClauseItem) + } + if let dbType = coordinator.databaseType, dbType != .mongodb && dbType != .redis, coordinator.tableName != nil { @@ -311,20 +342,18 @@ class DataGridRowView: NSTableRowView { coordinator?.undoDeleteRow(at: rowIndex) } + private func selectedOrCurrentIndices(in coordinator: TableViewCoordinator) -> Set { + coordinator.selectedRowIndices.isEmpty ? [rowIndex] : coordinator.selectedRowIndices + } + @objc private func copySelectedOrCurrentRowWithHeaders() { - guard let coordinator = coordinator else { return } - let indices: Set = !coordinator.selectedRowIndices.isEmpty - ? coordinator.selectedRowIndices - : [rowIndex] - coordinator.copyRowsWithHeaders(at: indices) + guard let coordinator else { return } + coordinator.copyRowsWithHeaders(at: selectedOrCurrentIndices(in: coordinator)) } @objc private func copySelectedOrCurrentRow() { - guard let coordinator = coordinator else { return } - let indices: Set = !coordinator.selectedRowIndices.isEmpty - ? coordinator.selectedRowIndices - : [rowIndex] - coordinator.delegate?.dataGridCopyRows(indices) + guard let coordinator else { return } + coordinator.delegate?.dataGridCopyRows(selectedOrCurrentIndices(in: coordinator)) } @objc private func pasteRows() { @@ -371,18 +400,12 @@ class DataGridRowView: NSTableRowView { @objc private func copyAsInsert() { guard let coordinator else { return } - let indices: Set = !coordinator.selectedRowIndices.isEmpty - ? coordinator.selectedRowIndices - : [rowIndex] - coordinator.copyRowsAsInsert(at: indices) + coordinator.copyRowsAsInsert(at: selectedOrCurrentIndices(in: coordinator)) } @objc private func copyAsUpdate() { guard let coordinator else { return } - let indices: Set = !coordinator.selectedRowIndices.isEmpty - ? coordinator.selectedRowIndices - : [rowIndex] - coordinator.copyRowsAsUpdate(at: indices) + coordinator.copyRowsAsUpdate(at: selectedOrCurrentIndices(in: coordinator)) } @objc private func exportResults() { @@ -391,10 +414,27 @@ class DataGridRowView: NSTableRowView { @objc private func copyAsJson() { guard let coordinator else { return } - let indices: Set = !coordinator.selectedRowIndices.isEmpty - ? coordinator.selectedRowIndices - : [rowIndex] - coordinator.copyRowsAsJson(at: indices) + coordinator.copyRowsAsJson(at: selectedOrCurrentIndices(in: coordinator)) + } + + @objc private func copyAsCsv() { + guard let coordinator else { return } + coordinator.copyRowsAsCsv(at: selectedOrCurrentIndices(in: coordinator), includeHeaders: false) + } + + @objc private func copyAsCsvWithHeaders() { + guard let coordinator else { return } + coordinator.copyRowsAsCsv(at: selectedOrCurrentIndices(in: coordinator), includeHeaders: true) + } + + @objc private func copyAsMarkdown() { + guard let coordinator else { return } + coordinator.copyRowsAsMarkdown(at: selectedOrCurrentIndices(in: coordinator)) + } + + @objc private func copyAsInClause(_ sender: NSMenuItem) { + guard let coordinator, let columnIndex = sender.representedObject as? Int else { return } + coordinator.copyRowsAsInClause(at: selectedOrCurrentIndices(in: coordinator), columnIndex: columnIndex) } @objc private func previewForeignKey(_ sender: NSMenuItem) { diff --git a/TablePro/Views/Results/DataGridView+RowActions.swift b/TablePro/Views/Results/DataGridView+RowActions.swift index 57f6597f7..772af0cc6 100644 --- a/TablePro/Views/Results/DataGridView+RowActions.swift +++ b/TablePro/Views/Results/DataGridView+RowActions.swift @@ -2,8 +2,6 @@ // DataGridView+RowActions.swift // TablePro // -// Row action methods extracted from DataGridView for maintainability. -// import AppKit import os @@ -160,6 +158,64 @@ extension TableViewCoordinator { 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) } } + guard !rows.isEmpty else { return } + let tableRows = tableRowsProvider() + let converter = CsvRowConverter(columns: tableRows.columns, 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) } } + guard !rows.isEmpty else { return } + let tableRows = tableRowsProvider() + let converter = MarkdownTableConverter(columns: tableRows.columns, columnTypes: tableRows.columnTypes) + ClipboardService.shared.writeText(converter.generateMarkdown(rows: rows)) + } + + func copyRowsAsInClause(at indices: Set, columnIndex: Int) { + let rows: [[PluginCellValue]] = indices.sorted().compactMap { displayRow(at: $0).map { Array($0.values) } } + guard !rows.isEmpty else { return } + let tableRows = tableRowsProvider() + guard columnIndex >= 0, columnIndex < tableRows.columns.count else { return } + let driver = resolveDriver() + let converter = InClauseConverter( + columnIndex: columnIndex, + columnTypes: tableRows.columnTypes, + escapeStringLiteral: driver?.escapeStringLiteral + ) + ClipboardService.shared.writeText(converter.generateInClause(rows: rows)) + } + + func copyColumnValues(columnIndex: Int) { + let tableRows = tableRowsProvider() + guard columnIndex >= 0, columnIndex < tableRows.columns.count else { return } + let rowCount = sortedIDs?.count ?? tableRows.rows.count + guard rowCount > 0 else { return } + + let columnType = tableRows.columnTypes.indices.contains(columnIndex) + ? tableRows.columnTypes[columnIndex] + : nil + + var lines: [String] = [] + lines.reserveCapacity(rowCount) + + for rowIndex in 0.. [String] { values.enumerated().map { index, cell in switch cell { diff --git a/TablePro/Views/Results/Extensions/DataGridView+Sort.swift b/TablePro/Views/Results/Extensions/DataGridView+Sort.swift index 7c9745f2b..f6d1c0caf 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Sort.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Sort.swift @@ -91,6 +91,17 @@ extension TableViewCoordinator { copyItem.target = self menu.addItem(copyItem) + if let dataColumnIndex = dataColumnIndex(from: column.identifier) { + let copyValuesItem = NSMenuItem( + title: String(localized: "Copy Column Values"), + action: #selector(copyColumnValues(_:)), + keyEquivalent: "" + ) + copyValuesItem.representedObject = dataColumnIndex + copyValuesItem.target = self + menu.addItem(copyValuesItem) + } + let filterItem = NSMenuItem(title: String(localized: "Filter with column"), action: #selector(filterWithColumn(_:)), keyEquivalent: "") filterItem.representedObject = baseName filterItem.target = self @@ -195,6 +206,11 @@ extension TableViewCoordinator { ClipboardService.shared.writeText(columnName) } + @objc func copyColumnValues(_ sender: NSMenuItem) { + guard let columnIndex = sender.representedObject as? Int else { return } + copyColumnValues(columnIndex: columnIndex) + } + @objc func filterWithColumn(_ sender: NSMenuItem) { guard let columnName = sender.representedObject as? String else { return } delegate?.dataGridFilterColumn(columnName) diff --git a/TableProTests/Core/Services/RowOperationsManagerCopyTests.swift b/TableProTests/Core/Services/RowOperationsManagerCopyTests.swift index e29d71ebd..8c6d9c428 100644 --- a/TableProTests/Core/Services/RowOperationsManagerCopyTests.swift +++ b/TableProTests/Core/Services/RowOperationsManagerCopyTests.swift @@ -15,6 +15,11 @@ private final class MockClipboardProvider: ClipboardProvider { lastWasGridRows = false } + func writeCsv(_ csv: String) { + lastWrittenText = csv + lastWasGridRows = false + } + func writeRows(tsv: String, html: String?) { lastWrittenText = tsv lastWasGridRows = true diff --git a/TableProTests/Core/Utilities/CsvRowConverterTests.swift b/TableProTests/Core/Utilities/CsvRowConverterTests.swift new file mode 100644 index 000000000..890bf3c32 --- /dev/null +++ b/TableProTests/Core/Utilities/CsvRowConverterTests.swift @@ -0,0 +1,102 @@ +// +// CsvRowConverterTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit + +@testable import TablePro +import Testing + +@Suite("CSV Row Converter") +struct CsvRowConverterTests { + private func makeConverter(columns: [String], columnTypes: [ColumnType]) -> CsvRowConverter { + CsvRowConverter(columns: columns, columnTypes: columnTypes) + } + + @Test("Empty rows produces empty string") + func emptyRows() { + let converter = makeConverter(columns: ["id"], columnTypes: [.integer(rawType: nil)]) + let result = converter.generateCsv(rows: [], includeHeaders: false) + #expect(result == "") + } + + @Test("Plain values without quoting") + func plainValues() { + let converter = makeConverter( + columns: ["id", "name"], + columnTypes: [.integer(rawType: nil), .text(rawType: nil)] + ) + let result = converter.generateCsv(rows: [["1", "alice"], ["2", "bob"]], includeHeaders: false) + #expect(result == "1,alice\n2,bob\n") + } + + @Test("Headers prepended when requested") + func headers() { + let converter = makeConverter( + columns: ["id", "name"], + columnTypes: [.integer(rawType: nil), .text(rawType: nil)] + ) + let result = converter.generateCsv(rows: [["1", "alice"]], includeHeaders: true) + #expect(result == "id,name\n1,alice\n") + } + + @Test("NULL becomes empty field") + func nullEmpty() { + let converter = makeConverter( + columns: ["id", "name"], + columnTypes: [.integer(rawType: nil), .text(rawType: nil)] + ) + let result = converter.generateCsv(rows: [["1", nil]], includeHeaders: false) + #expect(result == "1,\n") + } + + @Test("Mid-row NULL keeps subsequent columns aligned") + func nullKeepsAlignment() { + let converter = makeConverter( + columns: ["a", "b", "c"], + columnTypes: Array(repeating: ColumnType.text(rawType: nil), count: 3) + ) + let result = converter.generateCsv(rows: [["a", nil, "c"]], includeHeaders: false) + #expect(result == "a,,c\n") + } + + @Test("Leading and trailing whitespace forces quoting") + func whitespaceQuoting() { + let converter = makeConverter(columns: ["name"], columnTypes: [.text(rawType: nil)]) + let result = converter.generateCsv(rows: [[" leading"], ["trailing "]], includeHeaders: false) + #expect(result == "\" leading\"\n\"trailing \"\n") + } + + @Test("Values with commas are quoted") + func commaQuoting() { + let converter = makeConverter(columns: ["name"], columnTypes: [.text(rawType: nil)]) + let result = converter.generateCsv(rows: [["doe, john"]], includeHeaders: false) + #expect(result == "\"doe, john\"\n") + } + + @Test("Embedded quotes are doubled") + func quoteEscape() { + let converter = makeConverter(columns: ["quote"], columnTypes: [.text(rawType: nil)]) + let result = converter.generateCsv(rows: [["he said \"hi\""]], includeHeaders: false) + #expect(result == "\"he said \"\"hi\"\"\"\n") + } + + @Test("Newlines force quoting") + func newlineQuoting() { + let converter = makeConverter(columns: ["note"], columnTypes: [.text(rawType: nil)]) + let result = converter.generateCsv(rows: [["line1\nline2"]], includeHeaders: false) + #expect(result == "\"line1\nline2\"\n") + } + + @Test("Header containing comma is quoted") + func quotedHeader() { + let converter = makeConverter( + columns: ["first, last"], + columnTypes: [.text(rawType: nil)] + ) + let result = converter.generateCsv(rows: [["alice"]], includeHeaders: true) + #expect(result == "\"first, last\"\nalice\n") + } +} diff --git a/TableProTests/Core/Utilities/InClauseConverterTests.swift b/TableProTests/Core/Utilities/InClauseConverterTests.swift new file mode 100644 index 000000000..2860b7fdc --- /dev/null +++ b/TableProTests/Core/Utilities/InClauseConverterTests.swift @@ -0,0 +1,105 @@ +// +// InClauseConverterTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit + +@testable import TablePro +import Testing + +@Suite("IN Clause Converter") +struct InClauseConverterTests { + private func makeConverter( + columnIndex: Int, + columnTypes: [ColumnType], + escape: ((String) -> String)? = nil + ) -> InClauseConverter { + InClauseConverter(columnIndex: columnIndex, columnTypes: columnTypes, escapeStringLiteral: escape) + } + + @Test("Empty rows yields empty parens") + func emptyRows() { + let converter = makeConverter(columnIndex: 0, columnTypes: [.integer(rawType: nil)]) + let result = converter.generateInClause(rows: []) + #expect(result == "()") + } + + @Test("Integer column produces unquoted values") + func integers() { + let converter = makeConverter(columnIndex: 0, columnTypes: [.integer(rawType: nil)]) + let result = converter.generateInClause(rows: [["1"], ["2"], ["3"]]) + #expect(result == "(1, 2, 3)") + } + + @Test("Text column produces quoted values") + func text() { + let converter = makeConverter(columnIndex: 0, columnTypes: [.text(rawType: nil)]) + let result = converter.generateInClause(rows: [["alice"], ["bob"]]) + #expect(result == "('alice', 'bob')") + } + + @Test("Single quote in text is escaped by doubling when no driver escape is provided") + func defaultEscape() { + let converter = makeConverter(columnIndex: 0, columnTypes: [.text(rawType: nil)]) + let result = converter.generateInClause(rows: [["O'Brien"]]) + #expect(result == "('O''Brien')") + } + + @Test("Driver-provided escape is used when available") + func driverEscape() { + let converter = makeConverter( + columnIndex: 0, + columnTypes: [.text(rawType: nil)], + escape: { $0.replacingOccurrences(of: "'", with: "\\'") } + ) + let result = converter.generateInClause(rows: [["O'Brien"]]) + #expect(result == "('O\\'Brien')") + } + + @Test("NULL values are excluded so the IN clause matches its non-NULL siblings cleanly") + func nullExcluded() { + let converter = makeConverter(columnIndex: 0, columnTypes: [.integer(rawType: nil)]) + let result = converter.generateInClause(rows: [["1"], [nil], ["3"]]) + #expect(result == "(1, 3)") + } + + @Test("All-NULL selection yields empty parens") + func allNull() { + let converter = makeConverter(columnIndex: 0, columnTypes: [.integer(rawType: nil)]) + let result = converter.generateInClause(rows: [[nil], [nil]]) + #expect(result == "()") + } + + @Test("Boolean true variants normalize to TRUE") + func boolTrue() { + let converter = makeConverter(columnIndex: 0, columnTypes: [.boolean(rawType: nil)]) + let result = converter.generateInClause(rows: [["true"], ["1"], ["yes"], ["on"]]) + #expect(result == "(TRUE, TRUE, TRUE, TRUE)") + } + + @Test("Boolean false variants normalize to FALSE") + func boolFalse() { + let converter = makeConverter(columnIndex: 0, columnTypes: [.boolean(rawType: nil)]) + let result = converter.generateInClause(rows: [["false"], ["0"], ["no"], ["off"]]) + #expect(result == "(FALSE, FALSE, FALSE, FALSE)") + } + + @Test("Decimal column emits unquoted values") + func decimals() { + let converter = makeConverter(columnIndex: 0, columnTypes: [.decimal(rawType: nil)]) + let result = converter.generateInClause(rows: [["3.14"], ["2.71"]]) + #expect(result == "(3.14, 2.71)") + } + + @Test("Picks the requested column index, not the first") + func columnIndexHonored() { + let converter = makeConverter( + columnIndex: 1, + columnTypes: [.text(rawType: nil), .integer(rawType: nil)] + ) + let result = converter.generateInClause(rows: [["alice", "1"], ["bob", "2"]]) + #expect(result == "(1, 2)") + } +} diff --git a/TableProTests/Core/Utilities/MarkdownTableConverterTests.swift b/TableProTests/Core/Utilities/MarkdownTableConverterTests.swift new file mode 100644 index 000000000..a7d056d73 --- /dev/null +++ b/TableProTests/Core/Utilities/MarkdownTableConverterTests.swift @@ -0,0 +1,61 @@ +// +// MarkdownTableConverterTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit + +@testable import TablePro +import Testing + +@Suite("Markdown Table Converter") +struct MarkdownTableConverterTests { + private func makeConverter(columns: [String], columnTypes: [ColumnType]) -> MarkdownTableConverter { + MarkdownTableConverter(columns: columns, columnTypes: columnTypes) + } + + @Test("Header and alignment rows always emitted") + func headerAlignment() { + let converter = makeConverter( + columns: ["id", "name"], + columnTypes: [.integer(rawType: nil), .text(rawType: nil)] + ) + let result = converter.generateMarkdown(rows: [["1", "alice"]]) + let lines = result.split(separator: "\n", omittingEmptySubsequences: false) + #expect(lines[0] == "| id | name |") + #expect(lines[1] == "| --- | --- |") + #expect(lines[2] == "| 1 | alice |") + } + + @Test("NULL renders as NULL literal") + func nullLiteral() { + let converter = makeConverter( + columns: ["id", "name"], + columnTypes: [.integer(rawType: nil), .text(rawType: nil)] + ) + let result = converter.generateMarkdown(rows: [["1", nil]]) + #expect(result.contains("| 1 | NULL |")) + } + + @Test("Pipe characters in cells are escaped") + func pipeEscape() { + let converter = makeConverter(columns: ["expr"], columnTypes: [.text(rawType: nil)]) + let result = converter.generateMarkdown(rows: [["a | b"]]) + #expect(result.contains("| a \\| b |")) + } + + @Test("Newlines in cells become
") + func newlineToBr() { + let converter = makeConverter(columns: ["note"], columnTypes: [.text(rawType: nil)]) + let result = converter.generateMarkdown(rows: [["line1\nline2"]]) + #expect(result.contains("line1
line2")) + } + + @Test("Empty columns yields empty string") + func emptyColumns() { + let converter = makeConverter(columns: [], columnTypes: []) + let result = converter.generateMarkdown(rows: [["x"]]) + #expect(result == "") + } +} diff --git a/TableProTests/Views/Results/Extensions/CellPasteRoutingTests.swift b/TableProTests/Views/Results/Extensions/CellPasteRoutingTests.swift index 67309772a..fc83a5aa9 100644 --- a/TableProTests/Views/Results/Extensions/CellPasteRoutingTests.swift +++ b/TableProTests/Views/Results/Extensions/CellPasteRoutingTests.swift @@ -29,6 +29,7 @@ private final class StubClipboard: ClipboardProvider { func readText() -> String? { text } func writeText(_ text: String) { self.text = text; hasGridRowsValue = false } + func writeCsv(_ csv: String) { self.text = csv; hasGridRowsValue = false } func writeRows(tsv: String, html: String?) { self.text = tsv; hasGridRowsValue = true } var hasText: Bool { text != nil } var hasGridRows: Bool { hasGridRowsValue } diff --git a/docs/features/data-grid.mdx b/docs/features/data-grid.mdx index dfa401fb2..37ccbb721 100644 --- a/docs/features/data-grid.mdx +++ b/docs/features/data-grid.mdx @@ -321,7 +321,23 @@ Use arrow keys to move between cells. Press `Enter` to edit, `Escape` to cancel. Click a cell to select it. Drag or Shift+click to select a range. Click row numbers for entire rows; Cmd+click for non-contiguous rows. -Select cells and press `Cmd+C` for TSV, `Cmd+Shift+C` for CSV, or `Cmd+Option+J` for JSON. +Select rows and press `Cmd+C` for TSV, `Cmd+Shift+C` for TSV with headers, or `Cmd+Option+J` for JSON. + +Right-click a row and choose **Copy as** for additional formats: + +| Format | Description | +|--------|-------------| +| Cell Value | The clicked cell's content | +| With Headers | TSV with the column names as the first line | +| JSON | A JSON array of row objects | +| CSV | RFC 4180 CSV; empty fields for NULL | +| CSV with Headers | Same as CSV with the header row prepended | +| Markdown | GitHub-flavored Markdown table | +| IN Clause | `('a', 'b', 'c')` from the clicked column across selected rows, ready for `WHERE col IN (...)` | +| INSERT Statement(s) | SQL INSERT for each row (SQL databases only) | +| UPDATE Statement(s) | SQL UPDATE for each row by primary key (SQL databases only) | + +Right-click a column header and choose **Copy Column Values** to copy every loaded value in that column, one per line. {/* Screenshot: Copy options in context menu */} From bb74ca727a469a5ed27cb8afca89cf1b21c8038c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Tue, 19 May 2026 12:48:29 +0700 Subject: [PATCH 2/2] refactor(datagrid): extract RowValueCopyFormatter and address review items (#1325) --- .../Core/Utilities/SQL/CsvRowConverter.swift | 15 ++----- .../Utilities/SQL/InClauseConverter.swift | 2 +- .../SQL/MarkdownTableConverter.swift | 17 ++----- .../Utilities/SQL/RowValueCopyFormatter.swift | 37 +++++++++++++++ .../Results/DataGridView+RowActions.swift | 13 ++---- .../Core/Services/ClipboardServiceTests.swift | 45 +++++++++++++++++++ .../Utilities/InClauseConverterTests.swift | 9 ++++ 7 files changed, 102 insertions(+), 36 deletions(-) create mode 100644 TablePro/Core/Utilities/SQL/RowValueCopyFormatter.swift create mode 100644 TableProTests/Core/Services/ClipboardServiceTests.swift diff --git a/TablePro/Core/Utilities/SQL/CsvRowConverter.swift b/TablePro/Core/Utilities/SQL/CsvRowConverter.swift index 1c123a38f..e4a3f0fd5 100644 --- a/TablePro/Core/Utilities/SQL/CsvRowConverter.swift +++ b/TablePro/Core/Utilities/SQL/CsvRowConverter.swift @@ -32,20 +32,11 @@ internal struct CsvRowConverter { if idx > 0 { result.append(",") } guard row.indices.contains(idx) else { continue } - let cell = row[idx] - switch cell { - case .null: + let columnType = columnTypes.indices.contains(idx) ? columnTypes[idx] : nil + guard let text = RowValueCopyFormatter.copyText(cell: row[idx], columnType: columnType) else { continue - case .text(let value): - let columnType = columnTypes.indices.contains(idx) ? columnTypes[idx] : nil - let formatted = (columnType?.isBlobType ?? false) - ? (value.formattedAsCompactHex() ?? value) - : value - result.append(Self.encode(formatted)) - case .bytes(let data): - let blob = String(data: data, encoding: .isoLatin1)?.formattedAsCompactHex() ?? "" - result.append(Self.encode(blob)) } + result.append(Self.encode(text)) } result.append("\n") } diff --git a/TablePro/Core/Utilities/SQL/InClauseConverter.swift b/TablePro/Core/Utilities/SQL/InClauseConverter.swift index a12e3be09..351736fd8 100644 --- a/TablePro/Core/Utilities/SQL/InClauseConverter.swift +++ b/TablePro/Core/Utilities/SQL/InClauseConverter.swift @@ -40,7 +40,7 @@ internal struct InClauseConverter { private func formatScalar(_ value: String, type: ColumnType) -> String { switch type { case .integer: - if let intVal = Int64(value) { return String(intVal) } + if RowValueCopyFormatter.isIntegerLiteral(value) { return value } return quoted(value) case .decimal: if Double(value) != nil { return value } diff --git a/TablePro/Core/Utilities/SQL/MarkdownTableConverter.swift b/TablePro/Core/Utilities/SQL/MarkdownTableConverter.swift index f8c14c684..0d18f46c4 100644 --- a/TablePro/Core/Utilities/SQL/MarkdownTableConverter.swift +++ b/TablePro/Core/Utilities/SQL/MarkdownTableConverter.swift @@ -40,20 +40,9 @@ internal struct MarkdownTableConverter { continue } - let cell = row[idx] - switch cell { - case .null: - result.append("NULL") - case .text(let value): - let columnType = columnTypes.indices.contains(idx) ? columnTypes[idx] : nil - let formatted = (columnType?.isBlobType ?? false) - ? (value.formattedAsCompactHex() ?? value) - : value - result.append(Self.encode(formatted)) - case .bytes(let data): - let blob = String(data: data, encoding: .isoLatin1)?.formattedAsCompactHex() ?? "" - result.append(Self.encode(blob)) - } + let columnType = columnTypes.indices.contains(idx) ? columnTypes[idx] : nil + let text = RowValueCopyFormatter.copyText(cell: row[idx], columnType: columnType) ?? "NULL" + result.append(Self.encode(text)) } result.append(" |\n") } diff --git a/TablePro/Core/Utilities/SQL/RowValueCopyFormatter.swift b/TablePro/Core/Utilities/SQL/RowValueCopyFormatter.swift new file mode 100644 index 000000000..d337c97eb --- /dev/null +++ b/TablePro/Core/Utilities/SQL/RowValueCopyFormatter.swift @@ -0,0 +1,37 @@ +// +// RowValueCopyFormatter.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +internal enum RowValueCopyFormatter { + static func copyText(cell: PluginCellValue, columnType: ColumnType?) -> String? { + switch cell { + case .null: + return nil + case .text(let value): + if columnType?.isBlobType ?? false { + return value.formattedAsCompactHex() ?? value + } + return value + case .bytes(let data): + return String(data: data, encoding: .isoLatin1)?.formattedAsCompactHex() ?? "" + } + } + + static func isIntegerLiteral(_ value: String) -> Bool { + var iter = value.unicodeScalars.makeIterator() + guard var first = iter.next() else { return false } + if first == "-" { + guard let next = iter.next() else { return false } + first = next + } + guard first >= "0" && first <= "9" else { return false } + while let next = iter.next() { + guard next >= "0" && next <= "9" else { return false } + } + return true + } +} diff --git a/TablePro/Views/Results/DataGridView+RowActions.swift b/TablePro/Views/Results/DataGridView+RowActions.swift index 772af0cc6..06f0a2f2b 100644 --- a/TablePro/Views/Results/DataGridView+RowActions.swift +++ b/TablePro/Views/Results/DataGridView+RowActions.swift @@ -191,7 +191,8 @@ extension TableViewCoordinator { func copyColumnValues(columnIndex: Int) { let tableRows = tableRowsProvider() guard columnIndex >= 0, columnIndex < tableRows.columns.count else { return } - let rowCount = sortedIDs?.count ?? tableRows.rows.count + let totalRows = sortedIDs?.count ?? tableRows.rows.count + let rowCount = min(totalRows, PluginRowLimits.emergencyMax) guard rowCount > 0 else { return } let columnType = tableRows.columnTypes.indices.contains(columnIndex) @@ -203,14 +204,8 @@ extension TableViewCoordinator { for rowIndex in 0..