diff --git a/CHANGELOG.md b/CHANGELOG.md index 9dd5da9f3..553f7417a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/TablePro/Core/Utilities/SQL/SQLRowToStatementConverter.swift b/TablePro/Core/Utilities/SQL/SQLRowToStatementConverter.swift new file mode 100644 index 000000000..6b42abc2f --- /dev/null +++ b/TablePro/Core/Utilities/SQL/SQLRowToStatementConverter.swift @@ -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) + } +} diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 0fca0ae95..158d1605b 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -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" : { @@ -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" : { @@ -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" : { diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index b99c8647b..6f0a749bf 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -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, diff --git a/TablePro/Views/Results/DataGridView+RowActions.swift b/TablePro/Views/Results/DataGridView+RowActions.swift index 9795d5967..83c1b5b57 100644 --- a/TablePro/Views/Results/DataGridView+RowActions.swift +++ b/TablePro/Views/Results/DataGridView+RowActions.swift @@ -102,4 +102,30 @@ extension TableViewCoordinator { let value = rowProvider.value(atRow: rowIndex, column: columnIndex) ?? "NULL" ClipboardService.shared.writeText(value) } + + func copyRowsAsInsert(at indices: Set) { + 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) { + 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)) + } } diff --git a/TablePro/Views/Results/DataGridView.swift b/TablePro/Views/Results/DataGridView.swift index f51d5cc6c..e2c9d1b7c 100644 --- a/TablePro/Views/Results/DataGridView.swift +++ b/TablePro/Views/Results/DataGridView.swift @@ -58,6 +58,8 @@ struct DataGridView: NSViewRepresentable { var typePickerColumns: Set? var connectionId: UUID? var databaseType: DatabaseType? + var tableName: String? + var primaryKeyColumn: String? @Binding var selectedRowIndices: Set @Binding var sortState: SortState @@ -248,6 +250,8 @@ struct DataGridView: NSViewRepresentable { coordinator.typePickerColumns = typePickerColumns coordinator.connectionId = connectionId coordinator.databaseType = databaseType + coordinator.tableName = tableName + coordinator.primaryKeyColumn = primaryKeyColumn coordinator.rebuildVisualStateCache() @@ -615,6 +619,8 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData var typePickerColumns: Set? var connectionId: UUID? var databaseType: DatabaseType? + var tableName: String? + var primaryKeyColumn: String? /// Check if undo is available func canUndo() -> Bool { diff --git a/TablePro/Views/Results/TableRowViewWithMenu.swift b/TablePro/Views/Results/TableRowViewWithMenu.swift index 8c38049ae..29ac0863c 100644 --- a/TablePro/Views/Results/TableRowViewWithMenu.swift +++ b/TablePro/Views/Results/TableRowViewWithMenu.swift @@ -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") @@ -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 = !coordinator.selectedRowIndices.isEmpty + ? coordinator.selectedRowIndices + : [rowIndex] + coordinator.copyRowsAsInsert(at: indices) + } + + @objc private func copyAsUpdate() { + guard let coordinator else { return } + let indices: Set = !coordinator.selectedRowIndices.isEmpty + ? coordinator.selectedRowIndices + : [rowIndex] + coordinator.copyRowsAsUpdate(at: indices) + } } diff --git a/TableProTests/Core/Utilities/SQLRowToStatementConverterTests.swift b/TableProTests/Core/Utilities/SQLRowToStatementConverterTests.swift new file mode 100644 index 000000000..3af0c62d3 --- /dev/null +++ b/TableProTests/Core/Utilities/SQLRowToStatementConverterTests.swift @@ -0,0 +1,186 @@ +// +// SQLRowToStatementConverterTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("SQL Row To Statement Converter") +struct SQLRowToStatementConverterTests { + // MARK: - Factory + + private func makeConverter( + tableName: String = "users", + columns: [String] = ["id", "name", "email"], + primaryKeyColumn: String? = "id", + databaseType: DatabaseType = .mysql + ) -> SQLRowToStatementConverter { + SQLRowToStatementConverter( + tableName: tableName, + columns: columns, + primaryKeyColumn: primaryKeyColumn, + databaseType: databaseType + ) + } + + // MARK: - INSERT Generation + + @Test("Single row produces one INSERT statement") + func insertSingleRow() { + let converter = makeConverter() + let result = converter.generateInserts(rows: [["1", "Alice", "alice@example.com"]]) + #expect(result == "INSERT INTO `users` (`id`, `name`, `email`) VALUES ('1', 'Alice', 'alice@example.com');") + } + + @Test("Multiple rows are joined by newlines") + func insertMultipleRows() { + let converter = makeConverter() + let rows: [[String?]] = [ + ["1", "Alice", "alice@example.com"], + ["2", "Bob", "bob@example.com"] + ] + let result = converter.generateInserts(rows: rows) + let lines = result.components(separatedBy: "\n") + #expect(lines.count == 2) + #expect(lines[0] == "INSERT INTO `users` (`id`, `name`, `email`) VALUES ('1', 'Alice', 'alice@example.com');") + #expect(lines[1] == "INSERT INTO `users` (`id`, `name`, `email`) VALUES ('2', 'Bob', 'bob@example.com');") + } + + @Test("NULL values render as unquoted NULL") + func insertNullValues() { + let converter = makeConverter() + let result = converter.generateInserts(rows: [["1", nil, nil]]) + #expect(result == "INSERT INTO `users` (`id`, `name`, `email`) VALUES ('1', NULL, NULL);") + } + + @Test("Empty strings render as empty quoted string") + func insertEmptyStrings() { + let converter = makeConverter() + let result = converter.generateInserts(rows: [["1", "", ""]]) + #expect(result == "INSERT INTO `users` (`id`, `name`, `email`) VALUES ('1', '', '');") + } + + @Test("Single quotes in data are escaped as double single-quotes") + func insertSpecialCharactersSingleQuotes() { + let converter = makeConverter() + let result = converter.generateInserts(rows: [["1", "O'Brien", "o'brien@example.com"]]) + #expect(result == "INSERT INTO `users` (`id`, `name`, `email`) VALUES ('1', 'O''Brien', 'o''brien@example.com');") + } + + // MARK: - UPDATE Generation + + @Test("UPDATE with primary key excludes PK from SET and uses PK in WHERE") + func updateWithPrimaryKey() { + let converter = makeConverter() + let result = converter.generateUpdates(rows: [["1", "Alice", "alice@example.com"]]) + #expect(result == "UPDATE `users` SET `name` = 'Alice', `email` = 'alice@example.com' WHERE `id` = '1';") + } + + @Test("UPDATE without primary key uses all columns in SET and WHERE") + func updateWithoutPrimaryKey() { + let converter = makeConverter(primaryKeyColumn: nil) + let result = converter.generateUpdates(rows: [["1", "Alice", "alice@example.com"]]) + #expect(result == "UPDATE `users` SET `id` = '1', `name` = 'Alice', `email` = 'alice@example.com' WHERE `id` = '1' AND `name` = 'Alice' AND `email` = 'alice@example.com';") + } + + @Test("UPDATE without PK uses IS NULL in WHERE clause for NULL values") + func updateNullValuesInWhereClauseNoPK() { + let converter = makeConverter(primaryKeyColumn: nil) + let result = converter.generateUpdates(rows: [["1", nil, "alice@example.com"]]) + #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';") + } + + @Test("UPDATE with PK uses IS NULL in WHERE when PK value is NULL") + func updateNullPrimaryKeyValue() { + let converter = makeConverter() + let result = converter.generateUpdates(rows: [[nil, "Alice", "alice@example.com"]]) + #expect(result == "UPDATE `users` SET `name` = 'Alice', `email` = 'alice@example.com' WHERE `id` IS NULL;") + } + + // MARK: - Database-Specific Quoting + + @Test("ClickHouse uses ALTER TABLE ... UPDATE syntax") + func clickhouseUsesAlterTableUpdate() { + let converter = makeConverter(databaseType: .clickhouse) + let result = converter.generateUpdates(rows: [["1", "Alice", "alice@example.com"]]) + #expect(result == "ALTER TABLE `users` UPDATE `name` = 'Alice', `email` = 'alice@example.com' WHERE `id` = '1';") + } + + @Test("MSSQL uses bracket quoting") + func mssqlUsesBracketQuoting() { + let converter = makeConverter(databaseType: .mssql) + let result = converter.generateInserts(rows: [["1", "Alice", "alice@example.com"]]) + #expect(result == "INSERT INTO [users] ([id], [name], [email]) VALUES ('1', 'Alice', 'alice@example.com');") + } + + @Test("PostgreSQL uses double-quote quoting") + func postgresqlUsesDoubleQuoteQuoting() { + let converter = makeConverter(databaseType: .postgresql) + let result = converter.generateInserts(rows: [["1", "Alice", "alice@example.com"]]) + #expect(result == "INSERT INTO \"users\" (\"id\", \"name\", \"email\") VALUES ('1', 'Alice', 'alice@example.com');") + } + + @Test("MySQL uses backtick quoting") + func mysqlUsesBacktickQuoting() { + let converter = makeConverter(databaseType: .mysql) + let result = converter.generateInserts(rows: [["1", "Alice", "alice@example.com"]]) + #expect(result == "INSERT INTO `users` (`id`, `name`, `email`) VALUES ('1', 'Alice', 'alice@example.com');") + } + + @Test("DuckDB uses double-quote quoting and standard UPDATE syntax") + func duckdbUsesDoubleQuoteAndStandardUpdate() { + let converter = makeConverter(databaseType: .duckdb) + let insert = converter.generateInserts(rows: [["1", "Alice", "alice@example.com"]]) + #expect(insert == "INSERT INTO \"users\" (\"id\", \"name\", \"email\") VALUES ('1', 'Alice', 'alice@example.com');") + let update = converter.generateUpdates(rows: [["1", "Alice", "alice@example.com"]]) + #expect(update == "UPDATE \"users\" SET \"name\" = 'Alice', \"email\" = 'alice@example.com' WHERE \"id\" = '1';") + } + + @Test("MySQL escapes backslashes in values") + func mysqlBackslashEscaping() { + let converter = makeConverter(databaseType: .mysql) + let result = converter.generateInserts(rows: [["1", "C:\\Users\\test", "a@b.com"]]) + #expect(result == "INSERT INTO `users` (`id`, `name`, `email`) VALUES ('1', 'C:\\\\Users\\\\test', 'a@b.com');") + } + + @Test("PostgreSQL does not escape backslashes") + func postgresqlNoBackslashEscaping() { + let converter = makeConverter(databaseType: .postgresql) + let result = converter.generateInserts(rows: [["1", "C:\\Users\\test", "a@b.com"]]) + #expect(result == "INSERT INTO \"users\" (\"id\", \"name\", \"email\") VALUES ('1', 'C:\\Users\\test', 'a@b.com');") + } + + @Test("UPDATE falls back to all-column WHERE when PK not in columns") + func updatePkNotInColumnsFallsBack() { + let converter = makeConverter( + columns: ["name", "email"], + primaryKeyColumn: "id", + databaseType: .mysql + ) + let result = converter.generateUpdates(rows: [["Alice", "alice@example.com"]]) + #expect(result == "UPDATE `users` SET `name` = 'Alice', `email` = 'alice@example.com' WHERE `name` = 'Alice' AND `email` = 'alice@example.com';") + } + + // MARK: - Edge Cases + + @Test("Empty rows input returns empty string") + func emptyRowsReturnsEmptyString() { + let converter = makeConverter() + #expect(converter.generateInserts(rows: []) == "") + #expect(converter.generateUpdates(rows: []) == "") + } + + @Test("Row cap at 50,000 — 50,001 rows produces exactly 50,000 lines") + func rowCapAt50k() { + let converter = makeConverter( + columns: ["id", "name"], + primaryKeyColumn: "id" + ) + let rows: [[String?]] = (1...50_001).map { i in ["\(i)", "name\(i)"] } + let result = converter.generateInserts(rows: rows) + let lines = result.components(separatedBy: "\n") + #expect(lines.count == 50_000) + } +}