From 7592def541719afbb7afda495f10ce8c54408c49 Mon Sep 17 00:00:00 2001
From: t8y2 <1156263951@qq.com>
Date: Wed, 20 May 2026 19:00:42 +0800
Subject: [PATCH] feat(grid): add copy as UPDATE action
---
apps/desktop/src/components/grid/DataGrid.vue | 14 ++++-
.../src/composables/useDataGridExport.ts | 58 ++++++++++++++++++-
apps/desktop/src/i18n/locales/en.ts | 2 +
apps/desktop/src/i18n/locales/es.ts | 2 +
apps/desktop/src/i18n/locales/zh-CN.ts | 2 +
apps/desktop/src/lib/dataGridSql.ts | 51 ++++++++++++++++
packages/app-tests/dataGridSql.test.ts | 30 ++++++++++
7 files changed, 153 insertions(+), 6 deletions(-)
diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue
index 32f5cae3f..42152cef6 100644
--- a/apps/desktop/src/components/grid/DataGrid.vue
+++ b/apps/desktop/src/components/grid/DataGrid.vue
@@ -1077,6 +1077,10 @@ const visibleColumnIndexes = computed(() =>
visibleColumnIndexesForFilter(displayableColumnIndexes.value, hiddenColumnIndexes.value),
);
const visibleColumns = computed(() => visibleColumnIndexes.value.map((index) => props.result.columns[index]));
+const visibleSourceColumns = computed(() => {
+ if (!props.sourceColumns || props.sourceColumns.length !== props.result.columns.length) return undefined;
+ return visibleColumnIndexes.value.map((index) => props.sourceColumns?.[index]);
+});
const visibleColumnCount = computed(() => visibleColumnIndexes.value.length);
const displayableColumnCount = computed(() => displayableColumnIndexes.value.length);
const hiddenColumnCount = computed(() => displayableColumnCount.value - visibleColumnCount.value);
@@ -2080,6 +2084,8 @@ const {
copyCell,
copyRow,
copyRowAsInsert,
+ copyRowAsUpdate,
+ canCopyRowAsUpdate,
copyAll,
copySelectionTsv,
copySelectionCsv,
@@ -2094,10 +2100,9 @@ const {
columns: visibleColumns,
displayItems: visibleDisplayItems,
sql: computed(() => props.sql),
- tableMeta: computed(() =>
- props.tableMeta ? { schema: props.tableMeta.schema, tableName: props.tableMeta.tableName } : undefined,
- ),
+ tableMeta: computed(() => (props.tableMeta ? { ...props.tableMeta } : undefined)),
databaseType: computed(() => props.databaseType),
+ sourceColumns: visibleSourceColumns,
hasCellSelection,
selectedCells,
selectedRange,
@@ -4313,6 +4318,9 @@ defineExpose({
{{ isMultiRow ? t("grid.copyRowsInsert", { count: multiRowCount }) : t("grid.copyRowInsert") }}
+
+ {{ isMultiRow ? t("grid.copyRowsUpdate", { count: multiRowCount }) : t("grid.copyRowUpdate") }}
+
{{ t("grid.copyAll") }}
diff --git a/apps/desktop/src/composables/useDataGridExport.ts b/apps/desktop/src/composables/useDataGridExport.ts
index d790651ca..edd02f3ad 100644
--- a/apps/desktop/src/composables/useDataGridExport.ts
+++ b/apps/desktop/src/composables/useDataGridExport.ts
@@ -1,4 +1,4 @@
-import type { ComputedRef, Ref } from "vue";
+import { computed, type ComputedRef, type Ref } from "vue";
import { useI18n } from "vue-i18n";
import { isTauriRuntime } from "@/lib/tauriRuntime";
import { formatCsv, formatJson } from "@/lib/exportFormats";
@@ -14,6 +14,8 @@ import { useToast } from "@/composables/useToast";
import { displayCellValue, type CellValue } from "@/lib/cellValue";
import { tryStartExclusiveActivation, type ActionActivationGuard } from "@/lib/actionActivation";
import { copyToClipboard } from "@/lib/clipboard";
+import { buildDataGridCopyUpdateStatements } from "@/lib/dataGridSql";
+import type { DatabaseType } from "@/types/database";
interface RowItem {
id: number;
@@ -30,8 +32,9 @@ export interface UseDataGridExportOptions {
columns: ComputedRef;
displayItems: ComputedRef;
sql: ComputedRef;
- tableMeta: ComputedRef<{ schema?: string; tableName: string } | undefined>;
- databaseType: ComputedRef;
+ tableMeta: ComputedRef<{ schema?: string; tableName: string; primaryKeys: string[] } | undefined>;
+ databaseType: ComputedRef;
+ sourceColumns: ComputedRef | undefined>;
hasCellSelection: ComputedRef;
selectedCells: ComputedRef;
selectedRange: ComputedRef;
@@ -55,6 +58,8 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
displayItems,
sql,
tableMeta,
+ sourceColumns,
+ databaseType,
hasCellSelection,
selectedCells,
selectedRange,
@@ -81,6 +86,23 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
return displayItems.value.filter((item) => rowIdSet.has(item.id));
}
+ function targetedRows(): RowItem[] {
+ if (hasRowSelection.value && selectedRowIds.value.size > 0) {
+ return displayItems.value.filter((item) => selectedRowIds.value.has(item.id));
+ }
+ const range = selectedRange.value;
+ if (range && range.startRow !== range.endRow) {
+ return displayItems.value.slice(range.startRow, range.endRow + 1);
+ }
+ if (!contextCell.value) return [];
+ const item = getRowItem(contextCell.value.rowId);
+ return item ? [item] : [];
+ }
+
+ function updateEligibleRows(): RowItem[] {
+ return targetedRows().filter((item) => !item.isNew && !item.isDeleted);
+ }
+
// --- Selection copy functions ---
async function copySelectionTsv() {
if (!hasCellSelection.value) return;
@@ -174,6 +196,34 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
await copyText(`INSERT INTO ${table} (${cols}) VALUES (${vals});`);
}
+ async function copyRowAsUpdate() {
+ if (!tableMeta.value?.primaryKeys.length) return;
+ const statements = buildDataGridCopyUpdateStatements({
+ databaseType: databaseType.value,
+ tableMeta: tableMeta.value,
+ columns: columns.value,
+ sourceColumns: sourceColumns.value,
+ rows: updateEligibleRows().map((item) => item.data),
+ });
+ if (!statements.length) return;
+ await copyText(statements.join("\n"));
+ }
+
+ const canCopyRowAsUpdate = computed(() => {
+ if (!tableMeta.value?.primaryKeys.length) return false;
+ const rows = updateEligibleRows();
+ if (!rows.length) return false;
+ return (
+ buildDataGridCopyUpdateStatements({
+ databaseType: databaseType.value,
+ tableMeta: tableMeta.value,
+ columns: columns.value,
+ sourceColumns: sourceColumns.value,
+ rows: [rows[0].data],
+ }).length > 0
+ );
+ });
+
async function copyAll() {
const header = columns.value.join("\t");
const body = displayItems.value.map((item) => item.data.map((c) => displayCellValue(c)).join("\t")).join("\n");
@@ -320,6 +370,8 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
copyCell,
copyRow,
copyRowAsInsert,
+ copyRowAsUpdate,
+ canCopyRowAsUpdate,
copyAll,
copySelectionTsv,
copySelectionCsv,
diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts
index 2459ec3a1..d04ed4736 100644
--- a/apps/desktop/src/i18n/locales/en.ts
+++ b/apps/desktop/src/i18n/locales/en.ts
@@ -249,6 +249,7 @@ export default {
copyCell: "Copy Cell",
copyRow: "Copy Row (JSON)",
copyRowInsert: "Copy as INSERT",
+ copyRowUpdate: "Copy as UPDATE",
copyAll: "Copy All (TSV)",
selection: "Selection",
copySelectionTsv: "Copy Selection (TSV)",
@@ -344,6 +345,7 @@ export default {
restoreRows: "Restore {count} Rows",
copyRows: "Copy {count} Rows (JSON)",
copyRowsInsert: "Copy {count} Rows as INSERT",
+ copyRowsUpdate: "Copy {count} Rows as UPDATE",
selectedRows: "{count} rows selected",
restoreRow: "Restore Row",
statusClean: "Clean",
diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts
index 1bd69a7e7..f00cc522b 100644
--- a/apps/desktop/src/i18n/locales/es.ts
+++ b/apps/desktop/src/i18n/locales/es.ts
@@ -244,6 +244,7 @@ export default {
copyCell: "Copiar celda",
copyRow: "Copiar fila (JSON)",
copyRowInsert: "Copiar como INSERT",
+ copyRowUpdate: "Copiar como UPDATE",
copyAll: "Copiar todo (TSV)",
selection: "Selección",
copySelectionTsv: "Copiar selección (TSV)",
@@ -312,6 +313,7 @@ export default {
restoreRows: "Restaurar {count} filas",
copyRows: "Copiar {count} filas (JSON)",
copyRowsInsert: "Copiar {count} filas como INSERT",
+ copyRowsUpdate: "Copiar {count} filas como UPDATE",
selectedRows: "{count} filas seleccionadas",
restoreRow: "Restaurar fila",
statusClean: "Sin cambios",
diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts
index 23eb952a1..d99545449 100644
--- a/apps/desktop/src/i18n/locales/zh-CN.ts
+++ b/apps/desktop/src/i18n/locales/zh-CN.ts
@@ -247,6 +247,7 @@ export default {
copyCell: "复制单元格",
copyRow: "复制行 (JSON)",
copyRowInsert: "复制为 INSERT 语句",
+ copyRowUpdate: "复制为 UPDATE 语句",
copyAll: "复制全部 (TSV)",
selection: "选区",
copySelectionTsv: "复制选区 (TSV)",
@@ -341,6 +342,7 @@ export default {
restoreRows: "恢复 {count} 行",
copyRows: "复制 {count} 行 (JSON)",
copyRowsInsert: "复制 {count} 行为 INSERT",
+ copyRowsUpdate: "复制 {count} 行为 UPDATE",
selectedRows: "已选 {count} 行",
restoreRow: "恢复行",
statusClean: "未改",
diff --git a/apps/desktop/src/lib/dataGridSql.ts b/apps/desktop/src/lib/dataGridSql.ts
index b8568f467..aa655a073 100644
--- a/apps/desktop/src/lib/dataGridSql.ts
+++ b/apps/desktop/src/lib/dataGridSql.ts
@@ -29,6 +29,14 @@ export interface DataGridSaveStatementOptions {
newRows: GridCellValue[][];
}
+export interface DataGridCopyUpdateStatementOptions {
+ databaseType?: DatabaseType;
+ tableMeta: DataGridTableMeta;
+ columns: string[];
+ sourceColumns?: Array;
+ rows: GridCellValue[][];
+}
+
export interface DataGridSaveValidationOptions {
databaseType?: DatabaseType;
tableMeta?: DataGridTableMeta;
@@ -157,6 +165,49 @@ export function buildDataGridSaveStatements(options: DataGridSaveStatementOption
return statements;
}
+export function buildDataGridCopyUpdateStatements(options: DataGridCopyUpdateStatementOptions): string[] {
+ if (options.databaseType === "neo4j" || options.databaseType === "tdengine") return [];
+ const primaryKeys = options.tableMeta.primaryKeys;
+ if (primaryKeys.length === 0) return [];
+
+ const saveColumns = effectiveColumns(options.sourceColumns, options.columns);
+ const primaryKeyIndexes = primaryKeys.map((primaryKey) => findColumnIndex(saveColumns, primaryKey));
+ if (primaryKeyIndexes.some((index) => index === -1)) return [];
+
+ const primaryKeySet = new Set(primaryKeys.map((primaryKey) => normalizeColumnName(primaryKey)));
+ const writableIndexes = saveColumns
+ .map((column, index) => ({ column, index }))
+ .filter((entry): entry is { column: string; index: number } => !!entry.column)
+ .filter((entry) => !primaryKeySet.has(normalizeColumnName(entry.column)))
+ .filter((entry) => !isOracleRowId(options.databaseType, entry.column));
+
+ if (writableIndexes.length === 0) return [];
+
+ const table = qualifiedTableName({
+ databaseType: options.databaseType,
+ schema: options.tableMeta.schema,
+ tableName: options.tableMeta.tableName,
+ });
+
+ const statements: string[] = [];
+ for (const row of options.rows) {
+ if (primaryKeyIndexes.some((index) => row[index] === null || row[index] === undefined)) continue;
+ const sets = writableIndexes
+ .map(
+ ({ column, index }) =>
+ `${quoteIdent(options.databaseType, column)} = ${formatGridSqlLiteral(row[index], options.databaseType)}`,
+ )
+ .join(", ");
+ if (!sets) continue;
+ const where = primaryKeys
+ .map((primaryKey, index) => buildColumnPredicate(options.databaseType, primaryKey, row[primaryKeyIndexes[index]]))
+ .join(" AND ");
+ statements.push(`UPDATE ${table} SET ${sets} WHERE ${where};`);
+ }
+
+ return statements;
+}
+
function buildTdengineDataGridSaveStatements(options: DataGridSaveStatementOptions): string[] {
const saveColumns = effectiveColumns(options.sourceColumns, options.columns);
const statements: string[] = [];
diff --git a/packages/app-tests/dataGridSql.test.ts b/packages/app-tests/dataGridSql.test.ts
index 0ae71f2e6..03a615087 100644
--- a/packages/app-tests/dataGridSql.test.ts
+++ b/packages/app-tests/dataGridSql.test.ts
@@ -1,6 +1,7 @@
import { strict as assert } from "node:assert";
import test from "node:test";
import {
+ buildDataGridCopyUpdateStatements,
buildDataGridRollbackStatements,
buildDataGridSaveStatements,
dataGridSaveExecutionSchema,
@@ -40,6 +41,35 @@ test("builds SQL Server grid save statements with schema and bracket quoting", (
]);
});
+test("builds copy-as-update statements using primary keys and non-primary-key columns", () => {
+ const statements = buildDataGridCopyUpdateStatements({
+ databaseType: "postgres",
+ tableMeta: {
+ schema: "public",
+ tableName: "users",
+ primaryKeys: ["id"],
+ },
+ columns: ["id", "name", "status"],
+ rows: [[1, "Ada", "active"]],
+ });
+
+ assert.deepEqual(statements, [`UPDATE "public"."users" SET "name" = 'Ada', "status" = 'active' WHERE "id" = 1;`]);
+});
+
+test("skips copy-as-update statements when primary keys are unavailable", () => {
+ const statements = buildDataGridCopyUpdateStatements({
+ databaseType: "postgres",
+ tableMeta: {
+ tableName: "users",
+ primaryKeys: [],
+ },
+ columns: ["id", "name"],
+ rows: [[1, "Ada"]],
+ });
+
+ assert.deepEqual(statements, []);
+});
+
test("builds Access grid save statements with backtick identifiers", () => {
const statements = buildDataGridSaveStatements({
databaseType: "access",