Skip to content

Commit 22ccd83

Browse files
committed
feat: copy rows as INSERT/UPDATE SQL statements from context menu
1 parent 1d0c155 commit 22ccd83

8 files changed

Lines changed: 324 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- Copy as INSERT/UPDATE SQL statements from data grid context menu
13+
1014
## [0.17.0] - 2026-03-11
1115

1216
### Added
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
//
2+
// SQLRowToStatementConverter.swift
3+
// TablePro
4+
5+
import Foundation
6+
7+
struct SQLRowToStatementConverter {
8+
let tableName: String
9+
let columns: [String]
10+
let primaryKeyColumn: String?
11+
let databaseType: DatabaseType
12+
13+
private static let maxRows = 50_000
14+
15+
func generateInserts(rows: [[String?]]) -> String {
16+
let capped = rows.prefix(Self.maxRows)
17+
let quotedTable = quoteColumn(tableName)
18+
let quotedColumns = columns.map { quoteColumn($0) }.joined(separator: ", ")
19+
20+
return capped.map { row in
21+
let values = row.map { formatValue($0) }.joined(separator: ", ")
22+
return "INSERT INTO \(quotedTable) (\(quotedColumns)) VALUES (\(values));"
23+
}.joined(separator: "\n")
24+
}
25+
26+
func generateUpdates(rows: [[String?]]) -> String {
27+
let capped = rows.prefix(Self.maxRows)
28+
29+
return capped.map { row in
30+
buildUpdateStatement(row: row)
31+
}.joined(separator: "\n")
32+
}
33+
34+
// MARK: - Private Helpers
35+
36+
private func buildUpdateStatement(row: [String?]) -> String {
37+
let quotedTable = quoteColumn(tableName)
38+
39+
let setClause: String
40+
let whereClause: String
41+
42+
if let pkColumn = primaryKeyColumn {
43+
let pkIndex = columns.firstIndex(of: pkColumn)
44+
let pkValue = pkIndex.map { row.indices.contains($0) ? row[$0] : nil } ?? nil
45+
46+
let setClauses = columns.enumerated().compactMap { index, col -> String? in
47+
guard col != pkColumn else { return nil }
48+
let value = row.indices.contains(index) ? row[index] : nil
49+
return "\(quoteColumn(col)) = \(formatValue(value))"
50+
}
51+
setClause = setClauses.joined(separator: ", ")
52+
whereClause = "\(quoteColumn(pkColumn)) = \(formatValue(pkValue))"
53+
} else {
54+
let allClauses = columns.enumerated().map { index, col -> String in
55+
let value = row.indices.contains(index) ? row[index] : nil
56+
return "\(quoteColumn(col)) = \(formatValue(value))"
57+
}
58+
setClause = allClauses.joined(separator: ", ")
59+
60+
let whereParts = columns.enumerated().map { index, col -> String in
61+
let value = row.indices.contains(index) ? row[index] : nil
62+
if value == nil {
63+
return "\(quoteColumn(col)) IS NULL"
64+
}
65+
return "\(quoteColumn(col)) = \(formatValue(value))"
66+
}
67+
whereClause = whereParts.joined(separator: " AND ")
68+
}
69+
70+
switch databaseType {
71+
case .clickhouse:
72+
return "ALTER TABLE \(quotedTable) UPDATE \(setClause) WHERE \(whereClause);"
73+
default:
74+
return "UPDATE \(quotedTable) SET \(setClause) WHERE \(whereClause);"
75+
}
76+
}
77+
78+
private func formatValue(_ value: String?) -> String {
79+
guard let value else {
80+
return "NULL"
81+
}
82+
return "'\(value.replacingOccurrences(of: "'", with: "''"))'"
83+
}
84+
85+
private func quoteColumn(_ name: String) -> String {
86+
databaseType.quoteIdentifier(name)
87+
}
88+
}

TablePro/Resources/Localizable.xcstrings

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4029,6 +4029,9 @@
40294029
}
40304030
}
40314031
}
4032+
},
4033+
"Copy as" : {
4034+
40324035
},
40334036
"Copy as URL" : {
40344037
"localizations" : {
@@ -8399,6 +8402,9 @@
83998402
}
84008403
}
84018404
}
8405+
},
8406+
"INSERT Statement(s)" : {
8407+
84028408
},
84038409
"Inspector" : {
84048410
"localizations" : {
@@ -16585,6 +16591,9 @@
1658516591
}
1658616592
}
1658716593
}
16594+
},
16595+
"UPDATE Statement(s)" : {
16596+
1658816597
},
1658916598
"Updated" : {
1659016599
"localizations" : {

TablePro/Views/Main/Child/MainEditorContentView.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,8 @@ struct MainEditorContentView: View {
312312
},
313313
connectionId: connection.id,
314314
databaseType: connection.type,
315+
tableName: tab.tableName,
316+
primaryKeyColumn: changeManager.primaryKeyColumn,
315317
selectedRowIndices: $selectedRowIndices,
316318
sortState: sortStateBinding(for: tab),
317319
editingCell: $editingCell,

TablePro/Views/Results/DataGridView+RowActions.swift

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,4 +102,30 @@ extension TableViewCoordinator {
102102
let value = rowProvider.value(atRow: rowIndex, column: columnIndex) ?? "NULL"
103103
ClipboardService.shared.writeText(value)
104104
}
105+
106+
func copyRowsAsInsert(at indices: Set<Int>) {
107+
guard let tableName, let databaseType else { return }
108+
let converter = SQLRowToStatementConverter(
109+
tableName: tableName,
110+
columns: rowProvider.columns,
111+
primaryKeyColumn: primaryKeyColumn,
112+
databaseType: databaseType
113+
)
114+
let rows = indices.sorted().compactMap { rowProvider.rowValues(at: $0) }
115+
guard !rows.isEmpty else { return }
116+
ClipboardService.shared.writeText(converter.generateInserts(rows: rows))
117+
}
118+
119+
func copyRowsAsUpdate(at indices: Set<Int>) {
120+
guard let tableName, let databaseType else { return }
121+
let converter = SQLRowToStatementConverter(
122+
tableName: tableName,
123+
columns: rowProvider.columns,
124+
primaryKeyColumn: primaryKeyColumn,
125+
databaseType: databaseType
126+
)
127+
let rows = indices.sorted().compactMap { rowProvider.rowValues(at: $0) }
128+
guard !rows.isEmpty else { return }
129+
ClipboardService.shared.writeText(converter.generateUpdates(rows: rows))
130+
}
105131
}

TablePro/Views/Results/DataGridView.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ struct DataGridView: NSViewRepresentable {
5858
var typePickerColumns: Set<Int>?
5959
var connectionId: UUID?
6060
var databaseType: DatabaseType?
61+
var tableName: String?
62+
var primaryKeyColumn: String?
6163

6264
@Binding var selectedRowIndices: Set<Int>
6365
@Binding var sortState: SortState
@@ -248,6 +250,8 @@ struct DataGridView: NSViewRepresentable {
248250
coordinator.typePickerColumns = typePickerColumns
249251
coordinator.connectionId = connectionId
250252
coordinator.databaseType = databaseType
253+
coordinator.tableName = tableName
254+
coordinator.primaryKeyColumn = primaryKeyColumn
251255

252256
coordinator.rebuildVisualStateCache()
253257

@@ -615,6 +619,8 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData
615619
var typePickerColumns: Set<Int>?
616620
var connectionId: UUID?
617621
var databaseType: DatabaseType?
622+
var tableName: String?
623+
var primaryKeyColumn: String?
618624

619625
/// Check if undo is available
620626
func canUndo() -> Bool {

TablePro/Views/Results/TableRowViewWithMenu.swift

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,34 @@ final class TableRowViewWithMenu: NSTableRowView {
8888
copyWithHeadersItem.target = self
8989
menu.addItem(copyWithHeadersItem)
9090

91+
// "Copy as" submenu — only for SQL databases with a known table
92+
if let dbType = coordinator.databaseType,
93+
dbType != .mongodb && dbType != .redis,
94+
coordinator.tableName != nil {
95+
let copyAsMenu = NSMenu()
96+
97+
let insertItem = NSMenuItem(
98+
title: String(localized: "INSERT Statement(s)"),
99+
action: #selector(copyAsInsert),
100+
keyEquivalent: "")
101+
insertItem.target = self
102+
copyAsMenu.addItem(insertItem)
103+
104+
let updateItem = NSMenuItem(
105+
title: String(localized: "UPDATE Statement(s)"),
106+
action: #selector(copyAsUpdate),
107+
keyEquivalent: "")
108+
updateItem.target = self
109+
copyAsMenu.addItem(updateItem)
110+
111+
let copyAsItem = NSMenuItem(
112+
title: String(localized: "Copy as"),
113+
action: nil,
114+
keyEquivalent: "")
115+
copyAsItem.submenu = copyAsMenu
116+
menu.addItem(copyAsItem)
117+
}
118+
91119
if coordinator.isEditable {
92120
let pasteItem = NSMenuItem(
93121
title: String(localized: "Paste"), action: #selector(pasteRows), keyEquivalent: "v")
@@ -194,4 +222,20 @@ final class TableRowViewWithMenu: NSTableRowView {
194222
guard let columnIndex = sender.representedObject as? Int else { return }
195223
coordinator?.setCellValueAtColumn("__DEFAULT__", at: rowIndex, columnIndex: columnIndex)
196224
}
225+
226+
@objc private func copyAsInsert() {
227+
guard let coordinator else { return }
228+
let indices: Set<Int> = !coordinator.selectedRowIndices.isEmpty
229+
? coordinator.selectedRowIndices
230+
: [rowIndex]
231+
coordinator.copyRowsAsInsert(at: indices)
232+
}
233+
234+
@objc private func copyAsUpdate() {
235+
guard let coordinator else { return }
236+
let indices: Set<Int> = !coordinator.selectedRowIndices.isEmpty
237+
? coordinator.selectedRowIndices
238+
: [rowIndex]
239+
coordinator.copyRowsAsUpdate(at: indices)
240+
}
197241
}
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
//
2+
// SQLRowToStatementConverterTests.swift
3+
// TableProTests
4+
//
5+
6+
import Foundation
7+
import Testing
8+
@testable import TablePro
9+
10+
@Suite("SQL Row To Statement Converter")
11+
struct SQLRowToStatementConverterTests {
12+
// MARK: - Factory
13+
14+
private func makeConverter(
15+
tableName: String = "users",
16+
columns: [String] = ["id", "name", "email"],
17+
primaryKeyColumn: String? = "id",
18+
databaseType: DatabaseType = .mysql
19+
) -> SQLRowToStatementConverter {
20+
SQLRowToStatementConverter(
21+
tableName: tableName,
22+
columns: columns,
23+
primaryKeyColumn: primaryKeyColumn,
24+
databaseType: databaseType
25+
)
26+
}
27+
28+
// MARK: - INSERT Generation
29+
30+
@Test("Single row produces one INSERT statement")
31+
func insertSingleRow() {
32+
let converter = makeConverter()
33+
let result = converter.generateInserts(rows: [["1", "Alice", "alice@example.com"]])
34+
#expect(result == "INSERT INTO `users` (`id`, `name`, `email`) VALUES ('1', 'Alice', 'alice@example.com');")
35+
}
36+
37+
@Test("Multiple rows are joined by newlines")
38+
func insertMultipleRows() {
39+
let converter = makeConverter()
40+
let rows: [[String?]] = [
41+
["1", "Alice", "alice@example.com"],
42+
["2", "Bob", "bob@example.com"]
43+
]
44+
let result = converter.generateInserts(rows: rows)
45+
let lines = result.components(separatedBy: "\n")
46+
#expect(lines.count == 2)
47+
#expect(lines[0] == "INSERT INTO `users` (`id`, `name`, `email`) VALUES ('1', 'Alice', 'alice@example.com');")
48+
#expect(lines[1] == "INSERT INTO `users` (`id`, `name`, `email`) VALUES ('2', 'Bob', 'bob@example.com');")
49+
}
50+
51+
@Test("NULL values render as unquoted NULL")
52+
func insertNullValues() {
53+
let converter = makeConverter()
54+
let result = converter.generateInserts(rows: [["1", nil, nil]])
55+
#expect(result == "INSERT INTO `users` (`id`, `name`, `email`) VALUES ('1', NULL, NULL);")
56+
}
57+
58+
@Test("Empty strings render as empty quoted string")
59+
func insertEmptyStrings() {
60+
let converter = makeConverter()
61+
let result = converter.generateInserts(rows: [["1", "", ""]])
62+
#expect(result == "INSERT INTO `users` (`id`, `name`, `email`) VALUES ('1', '', '');")
63+
}
64+
65+
@Test("Single quotes in data are escaped as double single-quotes")
66+
func insertSpecialCharactersSingleQuotes() {
67+
let converter = makeConverter()
68+
let result = converter.generateInserts(rows: [["1", "O'Brien", "o'brien@example.com"]])
69+
#expect(result == "INSERT INTO `users` (`id`, `name`, `email`) VALUES ('1', 'O''Brien', 'o''brien@example.com');")
70+
}
71+
72+
// MARK: - UPDATE Generation
73+
74+
@Test("UPDATE with primary key excludes PK from SET and uses PK in WHERE")
75+
func updateWithPrimaryKey() {
76+
let converter = makeConverter()
77+
let result = converter.generateUpdates(rows: [["1", "Alice", "alice@example.com"]])
78+
#expect(result == "UPDATE `users` SET `name` = 'Alice', `email` = 'alice@example.com' WHERE `id` = '1';")
79+
}
80+
81+
@Test("UPDATE without primary key uses all columns in SET and WHERE")
82+
func updateWithoutPrimaryKey() {
83+
let converter = makeConverter(primaryKeyColumn: nil)
84+
let result = converter.generateUpdates(rows: [["1", "Alice", "alice@example.com"]])
85+
#expect(result == "UPDATE `users` SET `id` = '1', `name` = 'Alice', `email` = 'alice@example.com' WHERE `id` = '1' AND `name` = 'Alice' AND `email` = 'alice@example.com';")
86+
}
87+
88+
@Test("UPDATE without PK uses IS NULL in WHERE clause for NULL values")
89+
func updateNullValuesInWhereClauseNoPK() {
90+
let converter = makeConverter(primaryKeyColumn: nil)
91+
let result = converter.generateUpdates(rows: [["1", nil, "alice@example.com"]])
92+
#expect(result == "UPDATE `users` SET `id` = '1', `name` = NULL, `email` = 'alice@example.com' WHERE `id` = '1' AND `name` IS NULL AND `email` = 'alice@example.com';")
93+
}
94+
95+
// MARK: - Database-Specific Quoting
96+
97+
@Test("ClickHouse uses ALTER TABLE ... UPDATE syntax")
98+
func clickhouseUsesAlterTableUpdate() {
99+
let converter = makeConverter(databaseType: .clickhouse)
100+
let result = converter.generateUpdates(rows: [["1", "Alice", "alice@example.com"]])
101+
#expect(result == "ALTER TABLE `users` UPDATE `name` = 'Alice', `email` = 'alice@example.com' WHERE `id` = '1';")
102+
}
103+
104+
@Test("MSSQL uses bracket quoting")
105+
func mssqlUsesBracketQuoting() {
106+
let converter = makeConverter(databaseType: .mssql)
107+
let result = converter.generateInserts(rows: [["1", "Alice", "alice@example.com"]])
108+
#expect(result == "INSERT INTO [users] ([id], [name], [email]) VALUES ('1', 'Alice', 'alice@example.com');")
109+
}
110+
111+
@Test("PostgreSQL uses double-quote quoting")
112+
func postgresqlUsesDoubleQuoteQuoting() {
113+
let converter = makeConverter(databaseType: .postgresql)
114+
let result = converter.generateInserts(rows: [["1", "Alice", "alice@example.com"]])
115+
#expect(result == "INSERT INTO \"users\" (\"id\", \"name\", \"email\") VALUES ('1', 'Alice', 'alice@example.com');")
116+
}
117+
118+
@Test("MySQL uses backtick quoting")
119+
func mysqlUsesBacktickQuoting() {
120+
let converter = makeConverter(databaseType: .mysql)
121+
let result = converter.generateInserts(rows: [["1", "Alice", "alice@example.com"]])
122+
#expect(result == "INSERT INTO `users` (`id`, `name`, `email`) VALUES ('1', 'Alice', 'alice@example.com');")
123+
}
124+
125+
// MARK: - Edge Cases
126+
127+
@Test("Empty rows input returns empty string")
128+
func emptyRowsReturnsEmptyString() {
129+
let converter = makeConverter()
130+
#expect(converter.generateInserts(rows: []) == "")
131+
#expect(converter.generateUpdates(rows: []) == "")
132+
}
133+
134+
@Test("Row cap at 50,000 — 50,001 rows produces exactly 50,000 lines")
135+
func rowCapAt50k() {
136+
let converter = makeConverter(
137+
columns: ["id", "name"],
138+
primaryKeyColumn: "id"
139+
)
140+
let rows: [[String?]] = (1...50_001).map { i in ["\(i)", "name\(i)"] }
141+
let result = converter.generateInserts(rows: rows)
142+
let lines = result.components(separatedBy: "\n")
143+
#expect(lines.count == 50_000)
144+
}
145+
}

0 commit comments

Comments
 (0)