Skip to content

Commit cf59609

Browse files
authored
Merge pull request #271 from datlechin/feat/copy-as-sql-statements
feat: copy rows as INSERT/UPDATE SQL statements
2 parents 70d9794 + 3aa3f4f commit cf59609

8 files changed

Lines changed: 410 additions & 0 deletions

File tree

CHANGELOG.md

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

1010
### Added
1111

12+
- Copy as INSERT/UPDATE SQL statements from data grid context menu
1213
- Plugin download count display in Browse Plugins — fetched from GitHub Releases API and cached for 1 hour
1314

1415
### Fixed
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
//
2+
// SQLRowToStatementConverter.swift
3+
// TablePro
4+
5+
import Foundation
6+
7+
internal struct SQLRowToStatementConverter {
8+
internal let tableName: String
9+
internal let columns: [String]
10+
internal let primaryKeyColumn: String?
11+
internal let databaseType: DatabaseType
12+
13+
private static let maxRows = 50_000
14+
15+
internal 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+
internal 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+
row.indices.contains(pkIndex) {
45+
let pkValue = row[pkIndex]
46+
47+
let setClauses = columns.enumerated().compactMap { index, col -> String? in
48+
guard col != pkColumn else { return nil }
49+
let value = row.indices.contains(index) ? row[index] : nil
50+
return "\(quoteColumn(col)) = \(formatValue(value))"
51+
}
52+
setClause = setClauses.joined(separator: ", ")
53+
if pkValue == nil {
54+
whereClause = "\(quoteColumn(pkColumn)) IS NULL"
55+
} else {
56+
whereClause = "\(quoteColumn(pkColumn)) = \(formatValue(pkValue))"
57+
}
58+
} else {
59+
let allClauses = columns.enumerated().map { index, col -> String in
60+
let value = row.indices.contains(index) ? row[index] : nil
61+
return "\(quoteColumn(col)) = \(formatValue(value))"
62+
}
63+
setClause = allClauses.joined(separator: ", ")
64+
65+
let whereParts = columns.enumerated().map { index, col -> String in
66+
let value = row.indices.contains(index) ? row[index] : nil
67+
if value == nil {
68+
return "\(quoteColumn(col)) IS NULL"
69+
}
70+
return "\(quoteColumn(col)) = \(formatValue(value))"
71+
}
72+
whereClause = whereParts.joined(separator: " AND ")
73+
}
74+
75+
switch databaseType {
76+
case .clickhouse:
77+
return "ALTER TABLE \(quotedTable) UPDATE \(setClause) WHERE \(whereClause);"
78+
default:
79+
return "UPDATE \(quotedTable) SET \(setClause) WHERE \(whereClause);"
80+
}
81+
}
82+
83+
private func formatValue(_ value: String?) -> String {
84+
guard let value else {
85+
return "NULL"
86+
}
87+
var escaped = value.replacingOccurrences(of: "'", with: "''")
88+
if databaseType == .mysql || databaseType == .mariadb {
89+
escaped = escaped.replacingOccurrences(of: "\\", with: "\\\\")
90+
}
91+
return "'\(escaped)'"
92+
}
93+
94+
private func quoteColumn(_ name: String) -> String {
95+
databaseType.quoteIdentifier(name)
96+
}
97+
}

TablePro/Resources/Localizable.xcstrings

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4065,6 +4065,22 @@
40654065
}
40664066
}
40674067
},
4068+
"Copy as" : {
4069+
"localizations" : {
4070+
"vi" : {
4071+
"stringUnit" : {
4072+
"state" : "translated",
4073+
"value" : "Sao chép dạng"
4074+
}
4075+
},
4076+
"zh-Hans" : {
4077+
"stringUnit" : {
4078+
"state" : "translated",
4079+
"value" : "复制为"
4080+
}
4081+
}
4082+
}
4083+
},
40684084
"Copy as URL" : {
40694085
"localizations" : {
40704086
"vi" : {
@@ -8451,6 +8467,22 @@
84518467
}
84528468
}
84538469
},
8470+
"INSERT Statement(s)" : {
8471+
"localizations" : {
8472+
"vi" : {
8473+
"stringUnit" : {
8474+
"state" : "translated",
8475+
"value" : "Câu lệnh INSERT"
8476+
}
8477+
},
8478+
"zh-Hans" : {
8479+
"stringUnit" : {
8480+
"state" : "translated",
8481+
"value" : "INSERT 语句"
8482+
}
8483+
}
8484+
}
8485+
},
84548486
"Inspector" : {
84558487
"localizations" : {
84568488
"vi" : {
@@ -16637,6 +16669,22 @@
1663716669
}
1663816670
}
1663916671
},
16672+
"UPDATE Statement(s)" : {
16673+
"localizations" : {
16674+
"vi" : {
16675+
"stringUnit" : {
16676+
"state" : "translated",
16677+
"value" : "Câu lệnh UPDATE"
16678+
}
16679+
},
16680+
"zh-Hans" : {
16681+
"stringUnit" : {
16682+
"state" : "translated",
16683+
"value" : "UPDATE 语句"
16684+
}
16685+
}
16686+
}
16687+
},
1664016688
"Updated" : {
1664116689
"localizations" : {
1664216690
"vi" : {

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
}

0 commit comments

Comments
 (0)