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