feat(grid): add copy as UPDATE action

This commit is contained in:
t8y2 2026-05-20 19:00:42 +08:00
parent 35bfad8aec
commit 7592def541
7 changed files with 153 additions and 6 deletions

View File

@ -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({
<ContextMenuItem @click="copyRowAsInsert">
{{ isMultiRow ? t("grid.copyRowsInsert", { count: multiRowCount }) : t("grid.copyRowInsert") }}
</ContextMenuItem>
<ContextMenuItem v-if="canCopyRowAsUpdate" @click="copyRowAsUpdate">
{{ isMultiRow ? t("grid.copyRowsUpdate", { count: multiRowCount }) : t("grid.copyRowUpdate") }}
</ContextMenuItem>
<ContextMenuItem @click="copyAll">{{ t("grid.copyAll") }}</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuSub>

View File

@ -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<string[]>;
displayItems: ComputedRef<RowItem[]>;
sql: ComputedRef<string | undefined>;
tableMeta: ComputedRef<{ schema?: string; tableName: string } | undefined>;
databaseType: ComputedRef<string | undefined>;
tableMeta: ComputedRef<{ schema?: string; tableName: string; primaryKeys: string[] } | undefined>;
databaseType: ComputedRef<DatabaseType | undefined>;
sourceColumns: ComputedRef<Array<string | undefined> | undefined>;
hasCellSelection: ComputedRef<boolean>;
selectedCells: ComputedRef<SelectionData>;
selectedRange: ComputedRef<CellSelectionRange | null>;
@ -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,

View File

@ -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",

View File

@ -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",

View File

@ -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: "未改",

View File

@ -29,6 +29,14 @@ export interface DataGridSaveStatementOptions {
newRows: GridCellValue[][];
}
export interface DataGridCopyUpdateStatementOptions {
databaseType?: DatabaseType;
tableMeta: DataGridTableMeta;
columns: string[];
sourceColumns?: Array<string | undefined>;
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[] = [];

View File

@ -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",