From 66d180f729447e818e565e39d251a175222aefcb Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Fri, 22 May 2026 11:55:08 +0800 Subject: [PATCH] feat(grid): copy insert without primary keys --- apps/desktop/src/components/grid/DataGrid.vue | 11 ++- .../src/composables/useDataGridExport.ts | 67 +++++++++++-------- 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 | 46 +++++++++++++ packages/app-tests/dataGridSql.test.ts | 53 +++++++++++++++ 7 files changed, 152 insertions(+), 31 deletions(-) diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 81ff50b459..1728e16d16 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -2155,7 +2155,9 @@ const { copyCell, copyRow, copyRowAsInsert, + copyRowAsInsertWithoutPrimaryKeys, copyRowAsUpdate, + canCopyRowAsInsertWithoutPrimaryKeys, canCopyRowAsUpdate, copyAll, copySelectionTsv, @@ -2179,8 +2181,6 @@ const { selectedRange, contextCell: exportContextCell, getRowItem: (rowId: number) => visibleDisplayItems.value.find((item) => item.id === rowId), - quoteIdent, - escapeVal, selectedRowIds, hasRowSelection, }); @@ -4473,6 +4473,13 @@ defineExpose({ {{ isMultiRow ? t("grid.copyRowsInsert", { count: multiRowCount }) : t("grid.copyRowInsert") }} + + {{ + isMultiRow + ? t("grid.copyRowsInsertWithoutPrimaryKeys", { count: multiRowCount }) + : t("grid.copyRowInsertWithoutPrimaryKeys") + }} + {{ isMultiRow ? t("grid.copyRowsUpdate", { count: multiRowCount }) : t("grid.copyRowUpdate") }} diff --git a/apps/desktop/src/composables/useDataGridExport.ts b/apps/desktop/src/composables/useDataGridExport.ts index edd02f3ade..e2a756a71c 100644 --- a/apps/desktop/src/composables/useDataGridExport.ts +++ b/apps/desktop/src/composables/useDataGridExport.ts @@ -14,7 +14,7 @@ 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 { buildDataGridCopyInsertStatement, buildDataGridCopyUpdateStatements } from "@/lib/dataGridSql"; import type { DatabaseType } from "@/types/database"; interface RowItem { @@ -42,8 +42,6 @@ export interface UseDataGridExportOptions { | Ref<{ rowId: number; rowIndex: number; col: number } | null> | ComputedRef<{ rowId: number; rowIndex: number; col: number } | null>; getRowItem: (rowId: number) => RowItem | undefined; - quoteIdent: (name: string) => string; - escapeVal: (value: CellValue) => string; selectedRowIds: Ref> | ComputedRef>; hasRowSelection: ComputedRef; } @@ -65,8 +63,6 @@ export function useDataGridExport(options: UseDataGridExportOptions) { selectedRange, contextCell, getRowItem, - quoteIdent, - escapeVal, selectedRowIds, hasRowSelection, } = options; @@ -168,32 +164,29 @@ export function useDataGridExport(options: UseDataGridExportOptions) { await copyText(JSON.stringify(obj, null, 2)); } + function insertEligibleRows(): RowItem[] { + return targetedRows(); + } + + async function copyRowAsInsertStatement(excludePrimaryKeys: boolean) { + const statement = buildDataGridCopyInsertStatement({ + databaseType: databaseType.value, + tableMeta: tableMeta.value, + columns: columns.value, + sourceColumns: sourceColumns.value, + rows: insertEligibleRows().map((item) => item.data), + excludePrimaryKeys, + }); + if (!statement) return; + await copyText(statement); + } + async function copyRowAsInsert() { - const table = tableMeta.value - ? (tableMeta.value.schema ? `${quoteIdent(tableMeta.value.schema)}.` : "") + quoteIdent(tableMeta.value.tableName) - : "table_name"; - const cols = columns.value.map((c) => quoteIdent(c)).join(", "); + await copyRowAsInsertStatement(false); + } - if (hasRowSelection.value && selectedRowIds.value.size > 0) { - const items = displayItems.value.filter((item) => selectedRowIds.value.has(item.id)); - const valueRows = items.map((item) => `(${item.data.map((v) => escapeVal(v)).join(", ")})`); - await copyText(`INSERT INTO ${table} (${cols}) VALUES\n${valueRows.join(",\n")};`); - return; - } - - const range = selectedRange.value; - if (range && range.startRow !== range.endRow) { - const items = displayItems.value.slice(range.startRow, range.endRow + 1); - const valueRows = items.map((item) => `(${item.data.map((v) => escapeVal(v)).join(", ")})`); - await copyText(`INSERT INTO ${table} (${cols}) VALUES\n${valueRows.join(",\n")};`); - return; - } - - if (!contextCell.value) return; - const item = getRowItem(contextCell.value.rowId); - if (!item) return; - const vals = item.data.map((v) => escapeVal(v)).join(", "); - await copyText(`INSERT INTO ${table} (${cols}) VALUES (${vals});`); + async function copyRowAsInsertWithoutPrimaryKeys() { + await copyRowAsInsertStatement(true); } async function copyRowAsUpdate() { @@ -224,6 +217,20 @@ export function useDataGridExport(options: UseDataGridExportOptions) { ); }); + const canCopyRowAsInsertWithoutPrimaryKeys = computed(() => { + if (!tableMeta.value?.primaryKeys.length) return false; + const rows = insertEligibleRows(); + if (!rows.length) return false; + return !!buildDataGridCopyInsertStatement({ + databaseType: databaseType.value, + tableMeta: tableMeta.value, + columns: columns.value, + sourceColumns: sourceColumns.value, + rows: [rows[0].data], + excludePrimaryKeys: true, + }); + }); + 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"); @@ -370,7 +377,9 @@ export function useDataGridExport(options: UseDataGridExportOptions) { copyCell, copyRow, copyRowAsInsert, + copyRowAsInsertWithoutPrimaryKeys, copyRowAsUpdate, + canCopyRowAsInsertWithoutPrimaryKeys, canCopyRowAsUpdate, copyAll, copySelectionTsv, diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 905d04e4a6..83384be924 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -255,6 +255,7 @@ export default { copyCell: "Copy Cell", copyRow: "Copy Row (JSON)", copyRowInsert: "Copy as INSERT", + copyRowInsertWithoutPrimaryKeys: "Copy as INSERT without Primary Keys", copyRowUpdate: "Copy as UPDATE", copyAll: "Copy All (TSV)", selection: "Selection", @@ -351,6 +352,7 @@ export default { restoreRows: "Restore {count} Rows", copyRows: "Copy {count} Rows (JSON)", copyRowsInsert: "Copy {count} Rows as INSERT", + copyRowsInsertWithoutPrimaryKeys: "Copy {count} Rows as INSERT without Primary Keys", copyRowsUpdate: "Copy {count} Rows as UPDATE", selectedRows: "{count} rows selected", restoreRow: "Restore Row", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index cf3aa3ebd1..a7f9786852 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -250,6 +250,7 @@ export default { copyCell: "Copiar celda", copyRow: "Copiar fila (JSON)", copyRowInsert: "Copiar como INSERT", + copyRowInsertWithoutPrimaryKeys: "Copiar como INSERT sin claves primarias", copyRowUpdate: "Copiar como UPDATE", copyAll: "Copiar todo (TSV)", selection: "Selección", @@ -319,6 +320,7 @@ export default { restoreRows: "Restaurar {count} filas", copyRows: "Copiar {count} filas (JSON)", copyRowsInsert: "Copiar {count} filas como INSERT", + copyRowsInsertWithoutPrimaryKeys: "Copiar {count} filas como INSERT sin claves primarias", copyRowsUpdate: "Copiar {count} filas como UPDATE", selectedRows: "{count} filas seleccionadas", restoreRow: "Restaurar fila", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 86fddb0708..704d7dd8f8 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -253,6 +253,7 @@ export default { copyCell: "复制单元格", copyRow: "复制行 (JSON)", copyRowInsert: "复制为 INSERT 语句", + copyRowInsertWithoutPrimaryKeys: "复制为 INSERT 语句(不含主键)", copyRowUpdate: "复制为 UPDATE 语句", copyAll: "复制全部 (TSV)", selection: "选区", @@ -348,6 +349,7 @@ export default { restoreRows: "恢复 {count} 行", copyRows: "复制 {count} 行 (JSON)", copyRowsInsert: "复制 {count} 行为 INSERT", + copyRowsInsertWithoutPrimaryKeys: "复制 {count} 行为 INSERT(不含主键)", copyRowsUpdate: "复制 {count} 行为 UPDATE", selectedRows: "已选 {count} 行", restoreRow: "恢复行", diff --git a/apps/desktop/src/lib/dataGridSql.ts b/apps/desktop/src/lib/dataGridSql.ts index 7709953d8d..ab8a22e9a3 100644 --- a/apps/desktop/src/lib/dataGridSql.ts +++ b/apps/desktop/src/lib/dataGridSql.ts @@ -45,6 +45,15 @@ export interface DataGridCopyUpdateStatementOptions { rows: GridCellValue[][]; } +export interface DataGridCopyInsertStatementOptions { + databaseType?: DatabaseType; + tableMeta?: DataGridTableMeta; + columns: string[]; + sourceColumns?: Array; + rows: GridCellValue[][]; + excludePrimaryKeys?: boolean; +} + export interface DataGridSaveValidationOptions { databaseType?: DatabaseType; tableMeta?: DataGridTableMeta; @@ -249,6 +258,43 @@ export function buildDataGridCopyUpdateStatements(options: DataGridCopyUpdateSta return statements; } +export function buildDataGridCopyInsertStatement(options: DataGridCopyInsertStatementOptions): string | undefined { + const saveColumns = effectiveColumns(options.sourceColumns, options.columns); + const columnInfo = columnInfoByName(options.tableMeta?.columns); + const primaryKeySet = new Set( + (options.tableMeta?.primaryKeys ?? []).map((primaryKey) => normalizeColumnName(primaryKey)), + ); + const insertableColumns = saveColumns + .map((column, index) => ({ column, index })) + .filter((entry): entry is { column: string; index: number } => !!entry.column) + .filter((entry) => !isOracleRowId(options.databaseType, entry.column)); + const insertColumns = insertableColumns.filter( + (entry) => !options.excludePrimaryKeys || !primaryKeySet.has(normalizeColumnName(entry.column)), + ); + + if (options.excludePrimaryKeys && insertColumns.length === insertableColumns.length) return undefined; + if (insertColumns.length === 0 || options.rows.length === 0) return undefined; + + const table = options.tableMeta + ? qualifiedTableName({ + databaseType: options.databaseType, + schema: options.tableMeta.schema, + tableName: options.tableMeta.tableName, + }) + : "table_name"; + const columns = insertColumns.map((entry) => quoteIdent(options.databaseType, entry.column)).join(", "); + const valueRows = options.rows.map( + (row) => + `(${insertColumns + .map(({ column, index }) => + formatGridSqlLiteral(row[index], options.databaseType, columnInfo.get(normalizeColumnName(column))), + ) + .join(", ")})`, + ); + + return `INSERT INTO ${table} (${columns}) VALUES${valueRows.length === 1 ? " " : "\n"}${valueRows.join(",\n")};`; +} + 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 7b5afd470c..37a140e4b0 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 { + buildDataGridCopyInsertStatement, buildDataGridCopyUpdateStatements, buildDataGridRollbackStatements, buildDataGridSaveStatements, @@ -56,6 +57,58 @@ test("builds copy-as-update statements using primary keys and non-primary-key co assert.deepEqual(statements, [`UPDATE "public"."users" SET "name" = 'Ada', "status" = 'active' WHERE "id" = 1;`]); }); +test("builds copy-as-insert statement excluding primary key columns", () => { + const statement = buildDataGridCopyInsertStatement({ + databaseType: "mysql", + tableMeta: { + tableName: "users", + primaryKeys: ["id"], + }, + columns: ["id", "login_name", "display_name"], + rows: [ + [1, "ada", "Ada"], + [2, "linus", "Linus"], + ], + excludePrimaryKeys: true, + }); + + assert.equal( + statement, + "INSERT INTO `users` (`login_name`, `display_name`) VALUES\n('ada', 'Ada'),\n('linus', 'Linus');", + ); +}); + +test("copy-as-insert excludes primary keys using source column names", () => { + const statement = buildDataGridCopyInsertStatement({ + databaseType: "mysql", + tableMeta: { + tableName: "users", + primaryKeys: ["user_id"], + }, + columns: ["id", "name"], + sourceColumns: ["user_id", "name"], + rows: [[7, "Ada"]], + excludePrimaryKeys: true, + }); + + assert.equal(statement, "INSERT INTO `users` (`name`) VALUES ('Ada');"); +}); + +test("copy-as-insert without primary keys is unavailable when no primary key columns are visible", () => { + const statement = buildDataGridCopyInsertStatement({ + databaseType: "postgres", + tableMeta: { + tableName: "users", + primaryKeys: ["id"], + }, + columns: ["name"], + rows: [["Ada"]], + excludePrimaryKeys: true, + }); + + assert.equal(statement, undefined); +}); + test("skips copy-as-update statements when primary keys are unavailable", () => { const statements = buildDataGridCopyUpdateStatements({ databaseType: "postgres",