From f7eec00c8ea68a4d995e72fee443629b46a09ecf Mon Sep 17 00:00:00 2001 From: zipg Date: Fri, 10 Jul 2026 19:43:25 +0800 Subject: [PATCH] fix(grid): reuse prepared row copy statements --- apps/desktop/src/components/grid/DataGrid.vue | 17 +- .../__tests__/useDataGridExport.spec.ts | 169 +++++++++++++++++ .../src/composables/useDataGridExport.ts | 170 ++++++++++++------ 3 files changed, 294 insertions(+), 62 deletions(-) create mode 100644 apps/desktop/src/composables/__tests__/useDataGridExport.spec.ts diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 0694392b77..bf0cc805a0 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -6185,9 +6185,8 @@ const { copyRowAsInsert, copyRowAsInsertWithoutPrimaryKeys, prefetchRowAsInsertStatement, - canCopyPreparedInsert, + canCopyRowAsInsert, prefetchRowAsUpdateStatement, - canCopyPreparedUpdate, copyRowAsUpdate, canCopyRowAsInsertWithoutPrimaryKeys, canCopyRowAsUpdate, @@ -8359,33 +8358,33 @@ function copySubmenu(): ContextMenuItem { items.push({ label: singleRowSelected ? t("grid.copySelectedRowTsvWithHeaders") : t("grid.copySelectedRowsTsvWithHeaders", { count: selectedRowCount.value }), action: copySelectedRowsTsvWithHeaders }); } if (isMultiRow.value) { - items.push({ label: labels.insertMerged, action: () => copyRowAsInsert("merged"), disabled: !canCopyPreparedInsert(false, "merged") }); - items.push({ label: labels.insertRowByRow, action: () => copyRowAsInsert("row-by-row"), disabled: !canCopyPreparedInsert(false, "row-by-row") }); + items.push({ label: labels.insertMerged, action: () => copyRowAsInsert("merged"), disabled: !canCopyRowAsInsert.value }); + items.push({ label: labels.insertRowByRow, action: () => copyRowAsInsert("row-by-row"), disabled: !canCopyRowAsInsert.value }); } else { - items.push({ label: labels.insert, action: () => copyRowAsInsert(), disabled: !canCopyPreparedInsert(false) }); + items.push({ label: labels.insert, action: () => copyRowAsInsert(), disabled: !canCopyRowAsInsert.value }); } if (canCopyRowAsInsertWithoutPrimaryKeys.value) { if (isMultiRow.value) { items.push({ label: labels.insertNoPkMerged, action: () => copyRowAsInsertWithoutPrimaryKeys("merged"), - disabled: !canCopyPreparedInsert(true, "merged"), + disabled: !canCopyRowAsInsertWithoutPrimaryKeys.value, }); items.push({ label: labels.insertNoPkRowByRow, action: () => copyRowAsInsertWithoutPrimaryKeys("row-by-row"), - disabled: !canCopyPreparedInsert(true, "row-by-row"), + disabled: !canCopyRowAsInsertWithoutPrimaryKeys.value, }); } else { items.push({ label: labels.insertNoPk, action: () => copyRowAsInsertWithoutPrimaryKeys(), - disabled: !canCopyPreparedInsert(true), + disabled: !canCopyRowAsInsertWithoutPrimaryKeys.value, }); } } if (canCopyRowAsUpdate.value) { - items.push({ label: labels.update, action: copyRowAsUpdate, disabled: !canCopyPreparedUpdate() }); + items.push({ label: labels.update, action: copyRowAsUpdate }); } items.push({ label: t("grid.copyAll"), action: copyAll }); items.push({ label: t("grid.copyColumnNames"), action: copyColumnNames }); diff --git a/apps/desktop/src/composables/__tests__/useDataGridExport.spec.ts b/apps/desktop/src/composables/__tests__/useDataGridExport.spec.ts new file mode 100644 index 0000000000..2dfe3fda9e --- /dev/null +++ b/apps/desktop/src/composables/__tests__/useDataGridExport.spec.ts @@ -0,0 +1,169 @@ +import { computed, ref } from "vue"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useDataGridExport, type UseDataGridExportOptions } from "@/composables/useDataGridExport"; +import { buildDataGridCopyInsertStatement, buildDataGridCopyUpdateStatements } from "@/lib/dataGrid/dataGridSql"; +import { copyToClipboard } from "@/lib/common/clipboard"; +import type { DataGridTableMeta } from "@/lib/dataGrid/dataGridSql"; + +const toast = vi.fn(); + +vi.mock("vue-i18n", () => ({ + useI18n: () => ({ t: (key: string, params?: { message?: string }) => (params?.message ? `${key}: ${params.message}` : key) }), +})); + +vi.mock("@/composables/useToast", () => ({ + useToast: () => ({ toast }), +})); + +vi.mock("@/lib/common/clipboard", () => ({ + copyToClipboard: vi.fn(), +})); + +vi.mock("@/lib/dataGrid/dataGridSql", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + buildDataGridCopyInsertStatement: vi.fn(), + buildDataGridCopyUpdateStatements: vi.fn(), + }; +}); + +interface Deferred { + promise: Promise; + resolve: (value: T) => void; + reject: (error: unknown) => void; +} + +function deferred(): Deferred { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +function row(data: unknown[]) { + return { + id: 1, + data, + isNew: false, + isDeleted: false, + isDirtyCol: data.map(() => false), + status: "", + }; +} + +function createExportState(tableMeta: DataGridTableMeta, columns = tableMeta.columns?.map((column) => column.name) ?? ["id", "name"]) { + const item = row(columns.map((column, index) => (column === "id" ? 1 : `value-${index}`))); + const options: UseDataGridExportOptions = { + columns: computed(() => columns), + displayItems: computed(() => [item]), + sql: computed(() => undefined), + tableMeta: computed(() => tableMeta), + databaseType: computed(() => "mysql"), + connectionId: computed(() => "connection-1"), + database: computed(() => "dbx"), + context: computed(() => "table-data"), + sourceColumns: computed(() => columns), + columnTypes: computed(() => columns.map(() => "varchar")), + whereInput: computed(() => undefined), + orderBy: computed(() => undefined), + exportBatchSize: computed(() => 1000), + hasCellSelection: computed(() => false), + selectedCells: computed(() => ({ columns: [], rows: [] })), + selectedRange: computed(() => null), + contextCell: ref({ rowId: item.id, rowIndex: 0, col: -1 }), + getRowItem: (rowId) => (rowId === item.id ? item : undefined), + selectedRowIds: ref(new Set()), + hasRowSelection: computed(() => false), + }; + return useDataGridExport(options); +} + +const editableTable: DataGridTableMeta = { + tableName: "users", + primaryKeys: ["id"], + columns: [ + { name: "id", data_type: "int", is_nullable: false, is_primary_key: true }, + { name: "name", data_type: "varchar", is_nullable: false }, + ], +}; + +describe("useDataGridExport prepared row statements", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("reuses an in-flight INSERT prefetch when the copy action runs", async () => { + const pending = deferred(); + vi.mocked(buildDataGridCopyInsertStatement).mockReturnValueOnce(pending.promise); + const state = createExportState(editableTable); + + const prefetch = state.prefetchRowAsInsertStatement(false); + const copy = state.copyRowAsInsert(); + await vi.waitFor(() => expect(buildDataGridCopyInsertStatement).toHaveBeenCalledTimes(1)); + pending.resolve("INSERT INTO users VALUES (1, 'Alice');"); + + await Promise.all([prefetch, copy]); + expect(copyToClipboard).toHaveBeenCalledWith("INSERT INTO users VALUES (1, 'Alice');"); + }); + + it("reuses an in-flight UPDATE prefetch on the first copy action", async () => { + const pending = deferred(); + vi.mocked(buildDataGridCopyUpdateStatements).mockReturnValueOnce(pending.promise); + const state = createExportState(editableTable); + + const prefetch = state.prefetchRowAsUpdateStatement(); + const copy = state.copyRowAsUpdate(); + await vi.waitFor(() => expect(buildDataGridCopyUpdateStatements).toHaveBeenCalledTimes(1)); + pending.resolve(["UPDATE users SET name = 'Alice' WHERE id = 1;"]); + + await Promise.all([prefetch, copy]); + expect(copyToClipboard).toHaveBeenCalledWith("UPDATE users SET name = 'Alice' WHERE id = 1;"); + }); + + it.each(["GENERATED ALWAYS AS (1)", "IDENTITY(1, 1)"])("disables copy-as-insert when every result column is non-insertable (%s)", (extra) => { + const state = createExportState( + { + tableName: "generated_values", + primaryKeys: [], + columns: [{ name: "computed_value", data_type: "int", is_nullable: true, extra }], + }, + ["computed_value"], + ); + + expect(state.canCopyRowAsInsert.value).toBe(false); + }); + + it("reports a shared builder failure when the user invokes copy", async () => { + const pending = deferred(); + vi.mocked(buildDataGridCopyInsertStatement).mockReturnValueOnce(pending.promise); + const state = createExportState(editableTable); + + const prefetch = state.prefetchRowAsInsertStatement(false); + const copy = state.copyRowAsInsert(); + await vi.waitFor(() => expect(buildDataGridCopyInsertStatement).toHaveBeenCalledTimes(1)); + pending.reject(new Error("builder unavailable")); + + await Promise.all([prefetch, copy]); + expect(toast).toHaveBeenCalledWith("grid.copyFailed: builder unavailable", 5000); + expect(copyToClipboard).not.toHaveBeenCalled(); + }); + + it("reports an UPDATE builder failure from the first copy action", async () => { + const pending = deferred(); + vi.mocked(buildDataGridCopyUpdateStatements).mockReturnValueOnce(pending.promise); + const state = createExportState(editableTable); + + const prefetch = state.prefetchRowAsUpdateStatement(); + const copy = state.copyRowAsUpdate(); + await vi.waitFor(() => expect(buildDataGridCopyUpdateStatements).toHaveBeenCalledTimes(1)); + pending.reject(new Error("update builder unavailable")); + + await Promise.all([prefetch, copy]); + expect(toast).toHaveBeenCalledWith("grid.copyFailed: update builder unavailable", 5000); + expect(copyToClipboard).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/desktop/src/composables/useDataGridExport.ts b/apps/desktop/src/composables/useDataGridExport.ts index 3a106acf78..89f75d28de 100644 --- a/apps/desktop/src/composables/useDataGridExport.ts +++ b/apps/desktop/src/composables/useDataGridExport.ts @@ -15,6 +15,7 @@ import { expandNestedJsonStringsForCopy } from "@/lib/common/jsonCopyValue"; import { buildMongoCopyInsertDocument, formatMongoShellLiteral, type MongoInputValue } from "@/lib/mongo/mongoDocumentValues"; import type { DatabaseType, QueryResult } from "@/types/database"; import type { QueryResultExportRequest } from "@/lib/backend/api"; +import { DBX_ROWID_COLUMN } from "@/lib/table/tableEditing"; interface RowItem { id: number; @@ -90,6 +91,7 @@ interface CopyStatementCache { text: string; loading: boolean; ready: boolean; + promise?: Promise; } export function useDataGridExport(options: UseDataGridExportOptions) { @@ -276,6 +278,14 @@ export function useDataGridExport(options: UseDataGridExportOptions) { } async function prefetchRowAsInsertStatement(excludePrimaryKeys: boolean, insertMode: DataGridCopyInsertMode = "merged") { + try { + await prepareRowAsInsertStatement(excludePrimaryKeys, insertMode); + } catch { + // Prefetch failures are reported only if the user invokes the copy action. + } + } + + async function prepareRowAsInsertStatement(excludePrimaryKeys: boolean, insertMode: DataGridCopyInsertMode = "merged"): Promise { const rows = insertEligibleRows(); if (!rows.length) { setInsertCopyCache(excludePrimaryKeys, insertMode, { @@ -288,16 +298,10 @@ export function useDataGridExport(options: UseDataGridExportOptions) { } const key = insertCopyKey(excludePrimaryKeys, insertMode); const current = insertCopyCache(excludePrimaryKeys, insertMode); - if ((current.loading || current.ready) && current.key === key) return; + if (current.ready && current.key === key) return current.text; + if (current.loading && current.key === key && current.promise) return current.promise; - setInsertCopyCache(excludePrimaryKeys, insertMode, { - key, - text: "", - loading: true, - ready: false, - }); - - try { + const promise = Promise.resolve().then(async () => { const statement = databaseType.value === "mongodb" ? buildMongoCopyInsertStatement({ @@ -319,22 +323,37 @@ export function useDataGridExport(options: UseDataGridExportOptions) { insertMode, }); const latest = insertCopyCache(excludePrimaryKeys, insertMode); - if (latest.key !== key) return; + if (latest.key !== key || latest.promise !== promise) return undefined; setInsertCopyCache(excludePrimaryKeys, insertMode, { key, text: statement ?? "", loading: false, ready: !!statement, }); - } catch { + return statement; + }); + + setInsertCopyCache(excludePrimaryKeys, insertMode, { + key, + text: "", + loading: true, + ready: false, + promise, + }); + + try { + return await promise; + } catch (error) { const latest = insertCopyCache(excludePrimaryKeys, insertMode); - if (latest.key !== key) return; - setInsertCopyCache(excludePrimaryKeys, insertMode, { - key, - text: "", - loading: false, - ready: false, - }); + if (latest.key === key && latest.promise === promise) { + setInsertCopyCache(excludePrimaryKeys, insertMode, { + key, + text: "", + loading: false, + ready: false, + }); + } + throw error; } } @@ -343,14 +362,29 @@ export function useDataGridExport(options: UseDataGridExportOptions) { return cache.ready && cache.key === insertCopyKey(excludePrimaryKeys, insertMode); } - function copyPreparedRowAsInsert(excludePrimaryKeys: boolean, insertMode: DataGridCopyInsertMode = "merged"): boolean { - if (!canCopyPreparedInsert(excludePrimaryKeys, insertMode)) return false; - void copyText(insertCopyCache(excludePrimaryKeys, insertMode).text); - return true; + async function copyPreparedRowAsInsert(excludePrimaryKeys: boolean, insertMode: DataGridCopyInsertMode = "merged"): Promise { + try { + const statement = await prepareRowAsInsertStatement(excludePrimaryKeys, insertMode); + if (!statement) return false; + await copyText(statement); + return true; + } catch (error: any) { + toast(t("grid.copyFailed", { message: error?.message || String(error) }), 5000); + return false; + } } async function prefetchRowAsUpdateStatement() { - if (!tableMeta.value?.primaryKeys.length) { + try { + await prepareRowAsUpdateStatement(); + } catch { + // Prefetch failures are reported only if the user invokes the copy action. + } + } + + async function prepareRowAsUpdateStatement(): Promise { + const currentTableMeta = tableMeta.value; + if (!currentTableMeta?.primaryKeys.length) { setUpdateCopyCache({ key: "", text: "", @@ -371,25 +405,19 @@ export function useDataGridExport(options: UseDataGridExportOptions) { } const key = updateCopyKey(); const current = copyRowUpdateCache.value; - if ((current.loading || current.ready) && current.key === key) return; + if (current.ready && current.key === key) return current.text; + if (current.loading && current.key === key && current.promise) return current.promise; - setUpdateCopyCache({ - key, - text: "", - loading: true, - ready: false, - }); - - try { + const promise = Promise.resolve().then(async () => { const statements = await buildDataGridCopyUpdateStatements({ databaseType: databaseType.value, - tableMeta: tableMeta.value, + tableMeta: currentTableMeta, columns: columns.value, sourceColumns: sourceColumns.value, rows: rows.map((item) => item.data), }); const latest = copyRowUpdateCache.value; - if (latest.key !== key) return; + if (latest.key !== key || latest.promise !== promise) return undefined; const text = statements.join("\n"); setUpdateCopyCache({ key, @@ -397,15 +425,30 @@ export function useDataGridExport(options: UseDataGridExportOptions) { loading: false, ready: statements.length > 0, }); - } catch { + return text || undefined; + }); + + setUpdateCopyCache({ + key, + text: "", + loading: true, + ready: false, + promise, + }); + + try { + return await promise; + } catch (error) { const latest = copyRowUpdateCache.value; - if (latest.key !== key) return; - setUpdateCopyCache({ - key, - text: "", - loading: false, - ready: false, - }); + if (latest.key === key && latest.promise === promise) { + setUpdateCopyCache({ + key, + text: "", + loading: false, + ready: false, + }); + } + throw error; } } @@ -414,10 +457,16 @@ export function useDataGridExport(options: UseDataGridExportOptions) { return cache.ready && cache.key === updateCopyKey(); } - function copyPreparedRowAsUpdate(): boolean { - if (!canCopyPreparedUpdate()) return false; - void copyText(copyRowUpdateCache.value.text); - return true; + async function copyPreparedRowAsUpdate(): Promise { + try { + const statement = await prepareRowAsUpdateStatement(); + if (!statement) return false; + await copyText(statement); + return true; + } catch (error: any) { + toast(t("grid.copyFailed", { message: error?.message || String(error) }), 5000); + return false; + } } // --- Selection copy functions --- @@ -512,15 +561,15 @@ export function useDataGridExport(options: UseDataGridExportOptions) { } async function copyRowAsInsert(insertMode: DataGridCopyInsertMode = "merged") { - copyPreparedRowAsInsert(false, insertMode); + await copyPreparedRowAsInsert(false, insertMode); } async function copyRowAsInsertWithoutPrimaryKeys(insertMode: DataGridCopyInsertMode = "merged") { - copyPreparedRowAsInsert(true, insertMode); + await copyPreparedRowAsInsert(true, insertMode); } async function copyRowAsUpdate() { - copyPreparedRowAsUpdate(); + await copyPreparedRowAsUpdate(); } const canCopyRowAsUpdate = computed(() => { @@ -535,14 +584,19 @@ export function useDataGridExport(options: UseDataGridExportOptions) { return saveColumns.some((column) => column && !primaryKeySet.has(normalizeColumnName(column))); }); + function insertableCopyColumnCount(excludePrimaryKeys: boolean): number { + const primaryKeySet = new Set((tableMeta.value?.primaryKeys ?? []).map(normalizeColumnName)); + return effectiveColumns(sourceColumns.value, columns.value).filter((column): column is string => !!column && !isCopyInsertOmittedColumn(databaseType.value, column, tableMeta.value) && (!excludePrimaryKeys || !primaryKeySet.has(normalizeColumnName(column)))).length; + } + + const canCopyRowAsInsert = computed(() => insertEligibleRows().length > 0 && insertableCopyColumnCount(false) > 0); + const canCopyRowAsInsertWithoutPrimaryKeys = computed(() => { if (!tableMeta.value?.primaryKeys.length) return false; const rows = insertEligibleRows(); if (!rows.length) return false; - const saveColumns = effectiveColumns(sourceColumns.value, columns.value); - const primaryKeySet = new Set(tableMeta.value.primaryKeys.map(normalizeColumnName)); - const insertableCount = saveColumns.filter(Boolean).length; - const insertColumnsCount = saveColumns.filter((column) => column && !primaryKeySet.has(normalizeColumnName(column))).length; + const insertableCount = insertableCopyColumnCount(false); + const insertColumnsCount = insertableCopyColumnCount(true); return insertColumnsCount > 0 && insertColumnsCount < insertableCount; }); @@ -1108,6 +1162,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) { prefetchRowAsInsertStatement, canCopyPreparedInsert, copyPreparedRowAsInsert, + canCopyRowAsInsert, prefetchRowAsUpdateStatement, canCopyPreparedUpdate, copyPreparedRowAsUpdate, @@ -1213,6 +1268,15 @@ function effectiveColumns(sourceColumns: Array | undefined, return sourceColumns; } +function isCopyInsertOmittedColumn(databaseType: DatabaseType | undefined, column: string, tableMeta: DataGridTableMeta | undefined): boolean { + if (databaseType === "oracle" && column.toUpperCase() === DBX_ROWID_COLUMN) return true; + const columnInfo = tableMeta?.columns?.find((item) => normalizeColumnName(item.name) === normalizeColumnName(column)); + const normalizedType = columnInfo?.data_type.trim().replace(/^"|"$/g, "").toLowerCase(); + if (databaseType === "postgres" && (normalizedType === "tsvector" || normalizedType?.endsWith(".tsvector"))) return true; + const extra = columnInfo?.extra?.toLowerCase() ?? ""; + return /\b(auto_increment|autoincrement|identity)\b/.test(extra) || (extra.includes("generated always as") && !extra.includes("identity")); +} + function findColumnIndex(columns: Array, target: string): number { const normalizedTarget = normalizeColumnName(target); return columns.findIndex((column) => (column ? normalizeColumnName(column) : "") === normalizedTarget);