fix(grid): reuse prepared row copy statements

This commit is contained in:
zipg 2026-07-10 19:43:25 +08:00 committed by GitHub
parent ae4bbe1564
commit f7eec00c8e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 294 additions and 62 deletions

View File

@ -6185,9 +6185,8 @@ const {
copyRowAsInsert, copyRowAsInsert,
copyRowAsInsertWithoutPrimaryKeys, copyRowAsInsertWithoutPrimaryKeys,
prefetchRowAsInsertStatement, prefetchRowAsInsertStatement,
canCopyPreparedInsert, canCopyRowAsInsert,
prefetchRowAsUpdateStatement, prefetchRowAsUpdateStatement,
canCopyPreparedUpdate,
copyRowAsUpdate, copyRowAsUpdate,
canCopyRowAsInsertWithoutPrimaryKeys, canCopyRowAsInsertWithoutPrimaryKeys,
canCopyRowAsUpdate, canCopyRowAsUpdate,
@ -8359,33 +8358,33 @@ function copySubmenu(): ContextMenuItem {
items.push({ label: singleRowSelected ? t("grid.copySelectedRowTsvWithHeaders") : t("grid.copySelectedRowsTsvWithHeaders", { count: selectedRowCount.value }), action: copySelectedRowsTsvWithHeaders }); items.push({ label: singleRowSelected ? t("grid.copySelectedRowTsvWithHeaders") : t("grid.copySelectedRowsTsvWithHeaders", { count: selectedRowCount.value }), action: copySelectedRowsTsvWithHeaders });
} }
if (isMultiRow.value) { if (isMultiRow.value) {
items.push({ label: labels.insertMerged, action: () => copyRowAsInsert("merged"), disabled: !canCopyPreparedInsert(false, "merged") }); items.push({ label: labels.insertMerged, action: () => copyRowAsInsert("merged"), disabled: !canCopyRowAsInsert.value });
items.push({ label: labels.insertRowByRow, action: () => copyRowAsInsert("row-by-row"), disabled: !canCopyPreparedInsert(false, "row-by-row") }); items.push({ label: labels.insertRowByRow, action: () => copyRowAsInsert("row-by-row"), disabled: !canCopyRowAsInsert.value });
} else { } 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 (canCopyRowAsInsertWithoutPrimaryKeys.value) {
if (isMultiRow.value) { if (isMultiRow.value) {
items.push({ items.push({
label: labels.insertNoPkMerged, label: labels.insertNoPkMerged,
action: () => copyRowAsInsertWithoutPrimaryKeys("merged"), action: () => copyRowAsInsertWithoutPrimaryKeys("merged"),
disabled: !canCopyPreparedInsert(true, "merged"), disabled: !canCopyRowAsInsertWithoutPrimaryKeys.value,
}); });
items.push({ items.push({
label: labels.insertNoPkRowByRow, label: labels.insertNoPkRowByRow,
action: () => copyRowAsInsertWithoutPrimaryKeys("row-by-row"), action: () => copyRowAsInsertWithoutPrimaryKeys("row-by-row"),
disabled: !canCopyPreparedInsert(true, "row-by-row"), disabled: !canCopyRowAsInsertWithoutPrimaryKeys.value,
}); });
} else { } else {
items.push({ items.push({
label: labels.insertNoPk, label: labels.insertNoPk,
action: () => copyRowAsInsertWithoutPrimaryKeys(), action: () => copyRowAsInsertWithoutPrimaryKeys(),
disabled: !canCopyPreparedInsert(true), disabled: !canCopyRowAsInsertWithoutPrimaryKeys.value,
}); });
} }
} }
if (canCopyRowAsUpdate.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.copyAll"), action: copyAll });
items.push({ label: t("grid.copyColumnNames"), action: copyColumnNames }); items.push({ label: t("grid.copyColumnNames"), action: copyColumnNames });

View File

@ -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<typeof import("@/lib/dataGrid/dataGridSql")>();
return {
...original,
buildDataGridCopyInsertStatement: vi.fn(),
buildDataGridCopyUpdateStatements: vi.fn(),
};
});
interface Deferred<T> {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (error: unknown) => void;
}
function deferred<T>(): Deferred<T> {
let resolve!: (value: T) => void;
let reject!: (error: unknown) => void;
const promise = new Promise<T>((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<number>()),
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<string | undefined>();
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<string[]>();
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<string | undefined>();
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<string[]>();
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();
});
});

View File

@ -15,6 +15,7 @@ import { expandNestedJsonStringsForCopy } from "@/lib/common/jsonCopyValue";
import { buildMongoCopyInsertDocument, formatMongoShellLiteral, type MongoInputValue } from "@/lib/mongo/mongoDocumentValues"; import { buildMongoCopyInsertDocument, formatMongoShellLiteral, type MongoInputValue } from "@/lib/mongo/mongoDocumentValues";
import type { DatabaseType, QueryResult } from "@/types/database"; import type { DatabaseType, QueryResult } from "@/types/database";
import type { QueryResultExportRequest } from "@/lib/backend/api"; import type { QueryResultExportRequest } from "@/lib/backend/api";
import { DBX_ROWID_COLUMN } from "@/lib/table/tableEditing";
interface RowItem { interface RowItem {
id: number; id: number;
@ -90,6 +91,7 @@ interface CopyStatementCache {
text: string; text: string;
loading: boolean; loading: boolean;
ready: boolean; ready: boolean;
promise?: Promise<string | undefined>;
} }
export function useDataGridExport(options: UseDataGridExportOptions) { export function useDataGridExport(options: UseDataGridExportOptions) {
@ -276,6 +278,14 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
} }
async function prefetchRowAsInsertStatement(excludePrimaryKeys: boolean, insertMode: DataGridCopyInsertMode = "merged") { 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<string | undefined> {
const rows = insertEligibleRows(); const rows = insertEligibleRows();
if (!rows.length) { if (!rows.length) {
setInsertCopyCache(excludePrimaryKeys, insertMode, { setInsertCopyCache(excludePrimaryKeys, insertMode, {
@ -288,16 +298,10 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
} }
const key = insertCopyKey(excludePrimaryKeys, insertMode); const key = insertCopyKey(excludePrimaryKeys, insertMode);
const current = insertCopyCache(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, { const promise = Promise.resolve().then(async () => {
key,
text: "",
loading: true,
ready: false,
});
try {
const statement = const statement =
databaseType.value === "mongodb" databaseType.value === "mongodb"
? buildMongoCopyInsertStatement({ ? buildMongoCopyInsertStatement({
@ -319,22 +323,37 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
insertMode, insertMode,
}); });
const latest = insertCopyCache(excludePrimaryKeys, insertMode); const latest = insertCopyCache(excludePrimaryKeys, insertMode);
if (latest.key !== key) return; if (latest.key !== key || latest.promise !== promise) return undefined;
setInsertCopyCache(excludePrimaryKeys, insertMode, { setInsertCopyCache(excludePrimaryKeys, insertMode, {
key, key,
text: statement ?? "", text: statement ?? "",
loading: false, loading: false,
ready: !!statement, 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); const latest = insertCopyCache(excludePrimaryKeys, insertMode);
if (latest.key !== key) return; if (latest.key === key && latest.promise === promise) {
setInsertCopyCache(excludePrimaryKeys, insertMode, { setInsertCopyCache(excludePrimaryKeys, insertMode, {
key, key,
text: "", text: "",
loading: false, loading: false,
ready: false, ready: false,
}); });
}
throw error;
} }
} }
@ -343,14 +362,29 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
return cache.ready && cache.key === insertCopyKey(excludePrimaryKeys, insertMode); return cache.ready && cache.key === insertCopyKey(excludePrimaryKeys, insertMode);
} }
function copyPreparedRowAsInsert(excludePrimaryKeys: boolean, insertMode: DataGridCopyInsertMode = "merged"): boolean { async function copyPreparedRowAsInsert(excludePrimaryKeys: boolean, insertMode: DataGridCopyInsertMode = "merged"): Promise<boolean> {
if (!canCopyPreparedInsert(excludePrimaryKeys, insertMode)) return false; try {
void copyText(insertCopyCache(excludePrimaryKeys, insertMode).text); const statement = await prepareRowAsInsertStatement(excludePrimaryKeys, insertMode);
return true; 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() { 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<string | undefined> {
const currentTableMeta = tableMeta.value;
if (!currentTableMeta?.primaryKeys.length) {
setUpdateCopyCache({ setUpdateCopyCache({
key: "", key: "",
text: "", text: "",
@ -371,25 +405,19 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
} }
const key = updateCopyKey(); const key = updateCopyKey();
const current = copyRowUpdateCache.value; 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({ const promise = Promise.resolve().then(async () => {
key,
text: "",
loading: true,
ready: false,
});
try {
const statements = await buildDataGridCopyUpdateStatements({ const statements = await buildDataGridCopyUpdateStatements({
databaseType: databaseType.value, databaseType: databaseType.value,
tableMeta: tableMeta.value, tableMeta: currentTableMeta,
columns: columns.value, columns: columns.value,
sourceColumns: sourceColumns.value, sourceColumns: sourceColumns.value,
rows: rows.map((item) => item.data), rows: rows.map((item) => item.data),
}); });
const latest = copyRowUpdateCache.value; const latest = copyRowUpdateCache.value;
if (latest.key !== key) return; if (latest.key !== key || latest.promise !== promise) return undefined;
const text = statements.join("\n"); const text = statements.join("\n");
setUpdateCopyCache({ setUpdateCopyCache({
key, key,
@ -397,15 +425,30 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
loading: false, loading: false,
ready: statements.length > 0, 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; const latest = copyRowUpdateCache.value;
if (latest.key !== key) return; if (latest.key === key && latest.promise === promise) {
setUpdateCopyCache({ setUpdateCopyCache({
key, key,
text: "", text: "",
loading: false, loading: false,
ready: false, ready: false,
}); });
}
throw error;
} }
} }
@ -414,10 +457,16 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
return cache.ready && cache.key === updateCopyKey(); return cache.ready && cache.key === updateCopyKey();
} }
function copyPreparedRowAsUpdate(): boolean { async function copyPreparedRowAsUpdate(): Promise<boolean> {
if (!canCopyPreparedUpdate()) return false; try {
void copyText(copyRowUpdateCache.value.text); const statement = await prepareRowAsUpdateStatement();
return true; 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 --- // --- Selection copy functions ---
@ -512,15 +561,15 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
} }
async function copyRowAsInsert(insertMode: DataGridCopyInsertMode = "merged") { async function copyRowAsInsert(insertMode: DataGridCopyInsertMode = "merged") {
copyPreparedRowAsInsert(false, insertMode); await copyPreparedRowAsInsert(false, insertMode);
} }
async function copyRowAsInsertWithoutPrimaryKeys(insertMode: DataGridCopyInsertMode = "merged") { async function copyRowAsInsertWithoutPrimaryKeys(insertMode: DataGridCopyInsertMode = "merged") {
copyPreparedRowAsInsert(true, insertMode); await copyPreparedRowAsInsert(true, insertMode);
} }
async function copyRowAsUpdate() { async function copyRowAsUpdate() {
copyPreparedRowAsUpdate(); await copyPreparedRowAsUpdate();
} }
const canCopyRowAsUpdate = computed(() => { const canCopyRowAsUpdate = computed(() => {
@ -535,14 +584,19 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
return saveColumns.some((column) => column && !primaryKeySet.has(normalizeColumnName(column))); 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(() => { const canCopyRowAsInsertWithoutPrimaryKeys = computed(() => {
if (!tableMeta.value?.primaryKeys.length) return false; if (!tableMeta.value?.primaryKeys.length) return false;
const rows = insertEligibleRows(); const rows = insertEligibleRows();
if (!rows.length) return false; if (!rows.length) return false;
const saveColumns = effectiveColumns(sourceColumns.value, columns.value); const insertableCount = insertableCopyColumnCount(false);
const primaryKeySet = new Set(tableMeta.value.primaryKeys.map(normalizeColumnName)); const insertColumnsCount = insertableCopyColumnCount(true);
const insertableCount = saveColumns.filter(Boolean).length;
const insertColumnsCount = saveColumns.filter((column) => column && !primaryKeySet.has(normalizeColumnName(column))).length;
return insertColumnsCount > 0 && insertColumnsCount < insertableCount; return insertColumnsCount > 0 && insertColumnsCount < insertableCount;
}); });
@ -1108,6 +1162,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
prefetchRowAsInsertStatement, prefetchRowAsInsertStatement,
canCopyPreparedInsert, canCopyPreparedInsert,
copyPreparedRowAsInsert, copyPreparedRowAsInsert,
canCopyRowAsInsert,
prefetchRowAsUpdateStatement, prefetchRowAsUpdateStatement,
canCopyPreparedUpdate, canCopyPreparedUpdate,
copyPreparedRowAsUpdate, copyPreparedRowAsUpdate,
@ -1213,6 +1268,15 @@ function effectiveColumns(sourceColumns: Array<string | undefined> | undefined,
return sourceColumns; 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<string | undefined>, target: string): number { function findColumnIndex(columns: Array<string | undefined>, target: string): number {
const normalizedTarget = normalizeColumnName(target); const normalizedTarget = normalizeColumnName(target);
return columns.findIndex((column) => (column ? normalizeColumnName(column) : "") === normalizedTarget); return columns.findIndex((column) => (column ? normalizeColumnName(column) : "") === normalizedTarget);