From 22ccd831b93161861ab1e79bd62c9129bcc73f76 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 11 Mar 2026 14:31:16 +0700 Subject: [PATCH 1/3] feat: copy rows as INSERT/UPDATE SQL statements from context menu --- CHANGELOG.md | 4 + .../SQL/SQLRowToStatementConverter.swift | 88 +++++++++++ TablePro/Resources/Localizable.xcstrings | 9 ++ .../Main/Child/MainEditorContentView.swift | 2 + .../Results/DataGridView+RowActions.swift | 26 ++++ TablePro/Views/Results/DataGridView.swift | 6 + .../Views/Results/TableRowViewWithMenu.swift | 44 ++++++ .../SQLRowToStatementConverterTests.swift | 145 ++++++++++++++++++ 8 files changed, 324 insertions(+) create mode 100644 TablePro/Core/Utilities/SQL/SQLRowToStatementConverter.swift create mode 100644 TableProTests/Core/Utilities/SQLRowToStatementConverterTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 300f2277..e4a1f8ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Copy as INSERT/UPDATE SQL statements from data grid context menu + ## [0.17.0] - 2026-03-11 ### Added diff --git a/TablePro/Core/Utilities/SQL/SQLRowToStatementConverter.swift b/TablePro/Core/Utilities/SQL/SQLRowToStatementConverter.swift new file mode 100644 index 00000000..b8c82ea7 --- /dev/null +++ b/TablePro/Core/Utilities/SQL/SQLRowToStatementConverter.swift @@ -0,0 +1,88 @@ +// +// SQLRowToStatementConverter.swift +// TablePro + +import Foundation + +struct SQLRowToStatementConverter { + let tableName: String + let columns: [String] + let primaryKeyColumn: String? + let databaseType: DatabaseType + + private static let maxRows = 50_000 + + 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") + } + + 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) + let pkValue = pkIndex.map { row.indices.contains($0) ? row[$0] : nil } ?? nil + + 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: ", ") + 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" + } + return "'\(value.replacingOccurrences(of: "'", with: "''"))'" + } + + private func quoteColumn(_ name: String) -> String { + databaseType.quoteIdentifier(name) + } +} diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 53fe6e43..ad987648 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -4029,6 +4029,9 @@ } } } + }, + "Copy as" : { + }, "Copy as URL" : { "localizations" : { @@ -8399,6 +8402,9 @@ } } } + }, + "INSERT Statement(s)" : { + }, "Inspector" : { "localizations" : { @@ -16585,6 +16591,9 @@ } } } + }, + "UPDATE Statement(s)" : { + }, "Updated" : { "localizations" : { diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index b99c8647..6f0a749b 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 9795d596..83c1b5b5 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 f51d5cc6..e2c9d1b7 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 8c38049a..29ac0863 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 00000000..7c284f34 --- /dev/null +++ b/TableProTests/Core/Utilities/SQLRowToStatementConverterTests.swift @@ -0,0 +1,145 @@ +// +// SQLRowToStatementConverterTests.swift +// TableProTests +// + +import Foundation +import Testing +@testable import TablePro + +@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';") + } + + // 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');") + } + + // 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) + } +} From c3ee4761c0e6addfb74c5faf85e4324e0324f413 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 11 Mar 2026 14:37:32 +0700 Subject: [PATCH 2/3] fix: handle NULL primary key value with IS NULL in WHERE clause --- .../SQL/SQLRowToStatementConverter.swift | 6 +++++- .../SQLRowToStatementConverterTests.swift | 18 +++++++++++++++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/TablePro/Core/Utilities/SQL/SQLRowToStatementConverter.swift b/TablePro/Core/Utilities/SQL/SQLRowToStatementConverter.swift index b8c82ea7..222d5dbe 100644 --- a/TablePro/Core/Utilities/SQL/SQLRowToStatementConverter.swift +++ b/TablePro/Core/Utilities/SQL/SQLRowToStatementConverter.swift @@ -49,7 +49,11 @@ struct SQLRowToStatementConverter { return "\(quoteColumn(col)) = \(formatValue(value))" } setClause = setClauses.joined(separator: ", ") - whereClause = "\(quoteColumn(pkColumn)) = \(formatValue(pkValue))" + 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 diff --git a/TableProTests/Core/Utilities/SQLRowToStatementConverterTests.swift b/TableProTests/Core/Utilities/SQLRowToStatementConverterTests.swift index 7c284f34..7a4f15a6 100644 --- a/TableProTests/Core/Utilities/SQLRowToStatementConverterTests.swift +++ b/TableProTests/Core/Utilities/SQLRowToStatementConverterTests.swift @@ -4,8 +4,8 @@ // import Foundation -import Testing @testable import TablePro +import Testing @Suite("SQL Row To Statement Converter") struct SQLRowToStatementConverterTests { @@ -92,6 +92,13 @@ struct SQLRowToStatementConverterTests { #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") @@ -122,6 +129,15 @@ struct SQLRowToStatementConverterTests { #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';") + } + // MARK: - Edge Cases @Test("Empty rows input returns empty string") From 3aa3f4f538d557f3f74c3e79838005d403658681 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 11 Mar 2026 14:45:29 +0700 Subject: [PATCH 3/3] fix: address PR review - backslash escaping, access control, PK guard, translations --- .../SQL/SQLRowToStatementConverter.swift | 27 ++++++----- TablePro/Resources/Localizable.xcstrings | 45 +++++++++++++++++-- .../SQLRowToStatementConverterTests.swift | 25 +++++++++++ 3 files changed, 83 insertions(+), 14 deletions(-) diff --git a/TablePro/Core/Utilities/SQL/SQLRowToStatementConverter.swift b/TablePro/Core/Utilities/SQL/SQLRowToStatementConverter.swift index 222d5dbe..6b42abc2 100644 --- a/TablePro/Core/Utilities/SQL/SQLRowToStatementConverter.swift +++ b/TablePro/Core/Utilities/SQL/SQLRowToStatementConverter.swift @@ -4,15 +4,15 @@ import Foundation -struct SQLRowToStatementConverter { - let tableName: String - let columns: [String] - let primaryKeyColumn: String? - let databaseType: DatabaseType +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 - func generateInserts(rows: [[String?]]) -> String { + internal func generateInserts(rows: [[String?]]) -> String { let capped = rows.prefix(Self.maxRows) let quotedTable = quoteColumn(tableName) let quotedColumns = columns.map { quoteColumn($0) }.joined(separator: ", ") @@ -23,7 +23,7 @@ struct SQLRowToStatementConverter { }.joined(separator: "\n") } - func generateUpdates(rows: [[String?]]) -> String { + internal func generateUpdates(rows: [[String?]]) -> String { let capped = rows.prefix(Self.maxRows) return capped.map { row in @@ -39,9 +39,10 @@ struct SQLRowToStatementConverter { let setClause: String let whereClause: String - if let pkColumn = primaryKeyColumn { - let pkIndex = columns.firstIndex(of: pkColumn) - let pkValue = pkIndex.map { row.indices.contains($0) ? row[$0] : nil } ?? nil + 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 } @@ -83,7 +84,11 @@ struct SQLRowToStatementConverter { guard let value else { return "NULL" } - return "'\(value.replacingOccurrences(of: "'", with: "''"))'" + var escaped = value.replacingOccurrences(of: "'", with: "''") + if databaseType == .mysql || databaseType == .mariadb { + escaped = escaped.replacingOccurrences(of: "\\", with: "\\\\") + } + return "'\(escaped)'" } private func quoteColumn(_ name: String) -> String { diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 050bf971..158d1605 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -4066,7 +4066,20 @@ } }, "Copy as" : { - + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sao chép dạng" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "复制为" + } + } + } }, "Copy as URL" : { "localizations" : { @@ -8455,7 +8468,20 @@ } }, "INSERT Statement(s)" : { - + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Câu lệnh INSERT" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "INSERT 语句" + } + } + } }, "Inspector" : { "localizations" : { @@ -16644,7 +16670,20 @@ } }, "UPDATE Statement(s)" : { - + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Câu lệnh UPDATE" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "UPDATE 语句" + } + } + } }, "Updated" : { "localizations" : { diff --git a/TableProTests/Core/Utilities/SQLRowToStatementConverterTests.swift b/TableProTests/Core/Utilities/SQLRowToStatementConverterTests.swift index 7a4f15a6..3af0c62d 100644 --- a/TableProTests/Core/Utilities/SQLRowToStatementConverterTests.swift +++ b/TableProTests/Core/Utilities/SQLRowToStatementConverterTests.swift @@ -138,6 +138,31 @@ struct SQLRowToStatementConverterTests { #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")