Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

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

### Fixed
Expand Down
97 changes: 97 additions & 0 deletions TablePro/Core/Utilities/SQL/SQLRowToStatementConverter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
//
// SQLRowToStatementConverter.swift
// TablePro

import Foundation

internal struct SQLRowToStatementConverter {
internal let tableName: String
internal let columns: [String]
internal let primaryKeyColumn: String?
internal let databaseType: DatabaseType

private static let maxRows = 50_000

internal func generateInserts(rows: [[String?]]) -> String {
let capped = rows.prefix(Self.maxRows)
let quotedTable = quoteColumn(tableName)
let quotedColumns = columns.map { quoteColumn($0) }.joined(separator: ", ")

return capped.map { row in
let values = row.map { formatValue($0) }.joined(separator: ", ")
return "INSERT INTO \(quotedTable) (\(quotedColumns)) VALUES (\(values));"
}.joined(separator: "\n")
}

internal func generateUpdates(rows: [[String?]]) -> String {
let capped = rows.prefix(Self.maxRows)

return capped.map { row in
buildUpdateStatement(row: row)
}.joined(separator: "\n")
}

// MARK: - Private Helpers

private func buildUpdateStatement(row: [String?]) -> String {
let quotedTable = quoteColumn(tableName)

let setClause: String
let whereClause: String

if let pkColumn = primaryKeyColumn,
let pkIndex = columns.firstIndex(of: pkColumn),
row.indices.contains(pkIndex) {
let pkValue = row[pkIndex]

let setClauses = columns.enumerated().compactMap { index, col -> String? in
guard col != pkColumn else { return nil }
let value = row.indices.contains(index) ? row[index] : nil
return "\(quoteColumn(col)) = \(formatValue(value))"
}
setClause = setClauses.joined(separator: ", ")
if pkValue == nil {
whereClause = "\(quoteColumn(pkColumn)) IS NULL"
} else {
whereClause = "\(quoteColumn(pkColumn)) = \(formatValue(pkValue))"
}
} else {
let allClauses = columns.enumerated().map { index, col -> String in
let value = row.indices.contains(index) ? row[index] : nil
return "\(quoteColumn(col)) = \(formatValue(value))"
}
setClause = allClauses.joined(separator: ", ")

let whereParts = columns.enumerated().map { index, col -> String in
let value = row.indices.contains(index) ? row[index] : nil
if value == nil {
return "\(quoteColumn(col)) IS NULL"
}
return "\(quoteColumn(col)) = \(formatValue(value))"
}
whereClause = whereParts.joined(separator: " AND ")
}

switch databaseType {
case .clickhouse:
return "ALTER TABLE \(quotedTable) UPDATE \(setClause) WHERE \(whereClause);"
default:
return "UPDATE \(quotedTable) SET \(setClause) WHERE \(whereClause);"
}
}

private func formatValue(_ value: String?) -> String {
guard let value else {
return "NULL"
}
var escaped = value.replacingOccurrences(of: "'", with: "''")
if databaseType == .mysql || databaseType == .mariadb {
escaped = escaped.replacingOccurrences(of: "\\", with: "\\\\")
}
return "'\(escaped)'"
}

private func quoteColumn(_ name: String) -> String {
databaseType.quoteIdentifier(name)
}
}
48 changes: 48 additions & 0 deletions TablePro/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -4065,6 +4065,22 @@
}
}
},
"Copy as" : {
"localizations" : {
"vi" : {
"stringUnit" : {
"state" : "translated",
"value" : "Sao chép dạng"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "复制为"
}
}
}
},
"Copy as URL" : {
"localizations" : {
"vi" : {
Expand Down Expand Up @@ -8451,6 +8467,22 @@
}
}
},
"INSERT Statement(s)" : {
"localizations" : {
"vi" : {
"stringUnit" : {
"state" : "translated",
"value" : "Câu lệnh INSERT"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "INSERT 语句"
}
}
}
},
"Inspector" : {
"localizations" : {
"vi" : {
Expand Down Expand Up @@ -16637,6 +16669,22 @@
}
}
},
"UPDATE Statement(s)" : {
"localizations" : {
"vi" : {
"stringUnit" : {
"state" : "translated",
"value" : "Câu lệnh UPDATE"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "UPDATE 语句"
}
}
}
},
"Updated" : {
"localizations" : {
"vi" : {
Expand Down
2 changes: 2 additions & 0 deletions TablePro/Views/Main/Child/MainEditorContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,8 @@ struct MainEditorContentView: View {
},
connectionId: connection.id,
databaseType: connection.type,
tableName: tab.tableName,
primaryKeyColumn: changeManager.primaryKeyColumn,
selectedRowIndices: $selectedRowIndices,
sortState: sortStateBinding(for: tab),
editingCell: $editingCell,
Expand Down
26 changes: 26 additions & 0 deletions TablePro/Views/Results/DataGridView+RowActions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -102,4 +102,30 @@ extension TableViewCoordinator {
let value = rowProvider.value(atRow: rowIndex, column: columnIndex) ?? "NULL"
ClipboardService.shared.writeText(value)
}

func copyRowsAsInsert(at indices: Set<Int>) {
guard let tableName, let databaseType else { return }
let converter = SQLRowToStatementConverter(
tableName: tableName,
columns: rowProvider.columns,
primaryKeyColumn: primaryKeyColumn,
databaseType: databaseType
)
let rows = indices.sorted().compactMap { rowProvider.rowValues(at: $0) }
guard !rows.isEmpty else { return }
ClipboardService.shared.writeText(converter.generateInserts(rows: rows))
}

func copyRowsAsUpdate(at indices: Set<Int>) {
guard let tableName, let databaseType else { return }
let converter = SQLRowToStatementConverter(
tableName: tableName,
columns: rowProvider.columns,
primaryKeyColumn: primaryKeyColumn,
databaseType: databaseType
)
let rows = indices.sorted().compactMap { rowProvider.rowValues(at: $0) }
guard !rows.isEmpty else { return }
ClipboardService.shared.writeText(converter.generateUpdates(rows: rows))
}
}
6 changes: 6 additions & 0 deletions TablePro/Views/Results/DataGridView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ struct DataGridView: NSViewRepresentable {
var typePickerColumns: Set<Int>?
var connectionId: UUID?
var databaseType: DatabaseType?
var tableName: String?
var primaryKeyColumn: String?

@Binding var selectedRowIndices: Set<Int>
@Binding var sortState: SortState
Expand Down Expand Up @@ -248,6 +250,8 @@ struct DataGridView: NSViewRepresentable {
coordinator.typePickerColumns = typePickerColumns
coordinator.connectionId = connectionId
coordinator.databaseType = databaseType
coordinator.tableName = tableName
coordinator.primaryKeyColumn = primaryKeyColumn

coordinator.rebuildVisualStateCache()

Expand Down Expand Up @@ -615,6 +619,8 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData
var typePickerColumns: Set<Int>?
var connectionId: UUID?
var databaseType: DatabaseType?
var tableName: String?
var primaryKeyColumn: String?

/// Check if undo is available
func canUndo() -> Bool {
Expand Down
44 changes: 44 additions & 0 deletions TablePro/Views/Results/TableRowViewWithMenu.swift
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,34 @@ final class TableRowViewWithMenu: NSTableRowView {
copyWithHeadersItem.target = self
menu.addItem(copyWithHeadersItem)

// "Copy as" submenu — only for SQL databases with a known table
if let dbType = coordinator.databaseType,
dbType != .mongodb && dbType != .redis,
coordinator.tableName != nil {
let copyAsMenu = NSMenu()

let insertItem = NSMenuItem(
title: String(localized: "INSERT Statement(s)"),
action: #selector(copyAsInsert),
keyEquivalent: "")
insertItem.target = self
copyAsMenu.addItem(insertItem)

let updateItem = NSMenuItem(
title: String(localized: "UPDATE Statement(s)"),
action: #selector(copyAsUpdate),
keyEquivalent: "")
updateItem.target = self
copyAsMenu.addItem(updateItem)

let copyAsItem = NSMenuItem(
title: String(localized: "Copy as"),
action: nil,
keyEquivalent: "")
copyAsItem.submenu = copyAsMenu
menu.addItem(copyAsItem)
}

if coordinator.isEditable {
let pasteItem = NSMenuItem(
title: String(localized: "Paste"), action: #selector(pasteRows), keyEquivalent: "v")
Expand Down Expand Up @@ -194,4 +222,20 @@ final class TableRowViewWithMenu: NSTableRowView {
guard let columnIndex = sender.representedObject as? Int else { return }
coordinator?.setCellValueAtColumn("__DEFAULT__", at: rowIndex, columnIndex: columnIndex)
}

@objc private func copyAsInsert() {
guard let coordinator else { return }
let indices: Set<Int> = !coordinator.selectedRowIndices.isEmpty
? coordinator.selectedRowIndices
: [rowIndex]
coordinator.copyRowsAsInsert(at: indices)
}

@objc private func copyAsUpdate() {
guard let coordinator else { return }
let indices: Set<Int> = !coordinator.selectedRowIndices.isEmpty
? coordinator.selectedRowIndices
: [rowIndex]
coordinator.copyRowsAsUpdate(at: indices)
}
}
Loading