fix(oceanbase): preserve partition DDL and toggle storage options

This commit is contained in:
efeire authored and GitHub committed 2026-09-18 01:59:42 +08:00
1 parent 8eba12ea2f
commit fe0dd02f2b
36 files changed
+870 -125

No files matched your search

@@ -1,10 +1,11 @@
import { readFileSync } from "node:fs";
import { shallowRef } from "vue";
import { shallowRef, reactive, nextTick, effectScope } from "vue";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ColumnInfo, TreeNode } from "@/types/database";
const source = readFileSync(new URL("../useSidebarTreeExportRuntime.ts", import.meta.url), "utf8");
const toastMock = vi.hoisted(() => vi.fn());
const copyToClipboardMock = vi.hoisted(() => vi.fn());
const addExportTaskMock = vi.hoisted(() => vi.fn());
const updateTableExportTaskMock = vi.hoisted(() => vi.fn());
const apiMock = vi.hoisted(() => ({
@@ -20,6 +21,7 @@ const apiMock = vi.hoisted(() => ({
}));
vi.mock("@/lib/backend/api", () => apiMock);
vi.mock("@/lib/common/clipboard", () => ({ copyToClipboard: copyToClipboardMock }));
vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: toastMock }) }));
vi.mock("@/composables/useExportTracker", () => ({ useExportTracker: () => ({ addTask: addExportTaskMock, updateTableExportTask: updateTableExportTaskMock }) }));
@@ -36,7 +38,7 @@ vi.mock("vue-i18n", () => ({
import { useSidebarTreeExportRuntime } from "@/composables/useSidebarTreeExportRuntime";
import { DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS } from "@/lib/dataGrid/dataGridCopyExtractor";
import { showStructurePreviewDialog, structurePreviewSql, structurePreviewTitle } from "@/components/sidebar/sidebarTreeDialogState";
import { isLoadingStructurePreview, showStructurePreviewDialog, structurePreviewDefaultFileName, structurePreviewError, structurePreviewSql, structurePreviewTitle } from "@/components/sidebar/sidebarTreeDialogState";
function functionBody(name: string): string {
const signature = new RegExp(`(?:async\\s+)?function\\s+${name}\\s*\\([^)]*\\)\\s*(?::\\s*[^\\{]+)?\\{`, "m").exec(source);
@@ -84,9 +86,44 @@ function exportSettings() {
};
}
function structureRuntimeFixture(databaseType: "mysql" | "oceanbase-oracle" = "mysql") {
const scope = effectScope();
const firstNode = { id: "first-table", type: "table", label: "A", connectionId: "conn-1", database: "db", schema: "app" } as TreeNode;
const secondNode = { ...firstNode, id: "second-table", label: "B" } as TreeNode;
const activeNode = shallowRef(firstNode);
const settingsStore = reactive({ editorSettings: { excludeDdlStorage: true } });
const connectionStore = { ensureConnected: vi.fn(), getConfig: () => ({ db_type: databaseType }), treeNodes: [firstNode, secondNode], selectedTreeNodeIds: [] };
const runtime = scope.run(() => useSidebarTreeExportRuntime({ activeNode, connectionStore: connectionStore as never, settingsStore: settingsStore as never, acceptedSelectionIds: () => null }))!;
return { scope, activeNode, secondNode, settingsStore, runtime };
}
describe("useSidebarTreeExportRuntime", () => {
it("toggles OceanBase structure exports from the original DDL without querying again", async () => {
const scope = effectScope();
const ddl = 'CREATE TABLE "T" ("ID" NUMBER) REPLICA_NUM=1 PCTFREE=0 PARTITION BY HASH("ID") PARTITIONS 2';
apiMock.getTableDdl.mockResolvedValue(ddl);
const node = { id: "ob-table", type: "table", label: "T", connectionId: "ob", database: "SYS", schema: "APP" } as TreeNode;
const settingsStore = reactive({ editorSettings: { excludeDdlStorage: true } });
const connectionStore = { ensureConnected: vi.fn(), getConfig: () => ({ db_type: "oceanbase-oracle" }), treeNodes: [node], selectedTreeNodeIds: [] };
const runtime = scope.run(() => useSidebarTreeExportRuntime({ activeNode: shallowRef(node), connectionStore: connectionStore as never, settingsStore: settingsStore as never, acceptedSelectionIds: () => null }))!;
try {
await runtime.exportStructure();
expect(structurePreviewSql.value).not.toContain("REPLICA_NUM");
expect(structurePreviewSql.value).toContain('PARTITION BY HASH("ID") PARTITIONS 2');
settingsStore.editorSettings.excludeDdlStorage = false;
await nextTick();
expect(structurePreviewSql.value).toBe(ddl + ";\n");
settingsStore.editorSettings.excludeDdlStorage = true;
await nextTick();
expect(structurePreviewSql.value).not.toContain("PCTFREE");
expect(apiMock.getTableDdl).toHaveBeenCalledOnce();
} finally {
scope.stop();
}
});
beforeEach(() => {
vi.clearAllMocks();
apiMock.getTableDdl.mockReset();
addExportTaskMock.mockImplementation((tableName: string, format: string, filePath: string) => ({
exportId: "export-1",
tableName,
@@ -102,6 +139,101 @@ describe("useSidebarTreeExportRuntime", () => {
structurePreviewTitle.value = "";
});
it.each(["older-first", "newer-first"])("isolates overlapping structure requests completed %s", async (order) => {
const { scope, activeNode, secondNode, runtime } = structureRuntimeFixture();
const firstDdl = deferred<string>();
const secondDdl = deferred<string>();
apiMock.getTableDdl.mockReturnValueOnce(firstDdl.promise).mockReturnValueOnce(secondDdl.promise);
const firstRequest = runtime.exportStructure();
await vi.waitFor(() => expect(apiMock.getTableDdl).toHaveBeenCalledTimes(1));
activeNode.value = secondNode;
const secondRequest = runtime.exportStructure();
await vi.waitFor(() => expect(apiMock.getTableDdl).toHaveBeenCalledTimes(2));
try {
if (order === "older-first") {
firstDdl.resolve("CREATE TABLE A(id INT)");
await firstRequest;
expect(isLoadingStructurePreview.value).toBe(true);
expect(structurePreviewSql.value).toBe("");
secondDdl.resolve("CREATE TABLE B(id INT)");
await secondRequest;
} else {
secondDdl.resolve("CREATE TABLE B(id INT)");
await secondRequest;
firstDdl.resolve("CREATE TABLE A(id INT)");
await firstRequest;
}
expect(structurePreviewSql.value).toBe("CREATE TABLE B(id INT);\n");
expect(structurePreviewDefaultFileName.value).toBe("B.sql");
expect(isLoadingStructurePreview.value).toBe(false);
await runtime.copyStructurePreview();
expect(copyToClipboardMock).toHaveBeenLastCalledWith("CREATE TABLE B(id INT);\n");
} finally {
firstDdl.resolve("CREATE TABLE A(id INT)");
secondDdl.resolve("CREATE TABLE B(id INT)");
await Promise.all([firstRequest, secondRequest]);
scope.stop();
}
});
it.each(["close", "dispose"])("ignores pending structure responses after %s", async (action) => {
const { scope, runtime } = structureRuntimeFixture();
const pendingDdl = deferred<string>();
apiMock.getTableDdl.mockReturnValueOnce(pendingDdl.promise);
const pendingRequest = runtime.exportStructure();
await vi.waitFor(() => expect(apiMock.getTableDdl).toHaveBeenCalledOnce());
if (action === "close") showStructurePreviewDialog.value = false;
else scope.stop();
pendingDdl.resolve("CREATE TABLE A(id INT)");
try {
await pendingRequest;
expect(structurePreviewSql.value).toBe("");
expect(isLoadingStructurePreview.value).toBe(false);
} finally {
scope.stop();
}
});
it("ignores an older failure after another structure request succeeds", async () => {
const { scope, activeNode, secondNode, runtime } = structureRuntimeFixture();
const firstDdl = deferred<string>();
apiMock.getTableDdl.mockReturnValueOnce(firstDdl.promise).mockResolvedValueOnce("CREATE TABLE B(id INT)");
const firstRequest = runtime.exportStructure();
await vi.waitFor(() => expect(apiMock.getTableDdl).toHaveBeenCalledOnce());
activeNode.value = secondNode;
await runtime.exportStructure();
firstDdl.reject(new Error("old request failed"));
try {
await firstRequest;
expect(structurePreviewSql.value).toBe("CREATE TABLE B(id INT);\n");
expect(structurePreviewError.value).toBe("");
expect(isLoadingStructurePreview.value).toBe(false);
} finally {
scope.stop();
}
});
it("uses the latest storage preference when a structure request completes", async () => {
const { scope, settingsStore, runtime } = structureRuntimeFixture("oceanbase-oracle");
const pendingDdl = deferred<string>();
const ddl = "CREATE TABLE A(id INT) PCTFREE=0";
apiMock.getTableDdl.mockReturnValueOnce(pendingDdl.promise);
const pendingRequest = runtime.exportStructure();
await vi.waitFor(() => expect(apiMock.getTableDdl).toHaveBeenCalledOnce());
settingsStore.editorSettings.excludeDdlStorage = false;
pendingDdl.resolve(ddl);
try {
await pendingRequest;
expect(structurePreviewSql.value).toBe(ddl + ";\n");
settingsStore.editorSettings.excludeDdlStorage = true;
await nextTick();
expect(structurePreviewSql.value).not.toContain("PCTFREE");
expect(apiMock.getTableDdl).toHaveBeenCalledOnce();
} finally {
scope.stop();
}
});
it("translates direct executeQuery errors for sidebar JSON export", async () => {
apiMock.executeQuery.mockRejectedValueOnce(new Error("The previous DuckDB query is still stopping. Please try again shortly."));
const activeNode = shallowRef({ id: "table-1", type: "table", label: "users", connectionId: "conn-1", database: "db", schema: "main", children: [] } as TreeNode);
@@ -201,13 +333,14 @@ describe("useSidebarTreeExportRuntime", () => {
const activeNode = shallowRef(first);
const connectionStore = {
ensureConnected: vi.fn(),
getConfig: vi.fn(() => ({ db_type: "postgres" })),
treeNodes: [group],
selectedTreeNodeIds: [second.id, first.id],
};
const runtime = useSidebarTreeExportRuntime({
activeNode,
connectionStore: connectionStore as never,
settingsStore: {} as never,
settingsStore: exportSettings() as never,
acceptedSelectionIds: () => null,
});
@@ -1,4 +1,5 @@
import { createApp, type ShallowRef } from "vue";
import { applyDdlStoragePreference } from "@/lib/sql/ddlStorage";
import { watch, createApp, getCurrentScope, onScopeDispose, type ShallowRef } from "vue";
import { useI18n } from "vue-i18n";
import i18n from "@/i18n";
import { useExportTracker, type ExportTask } from "@/composables/useExportTracker";
@@ -18,7 +19,18 @@ import { fetchTableDataForExport } from "@/lib/table/tableDataExport";
import { forceCsvTextForTemporalColumns } from "@/lib/dataGrid/columnFormatter";
import XlsxHeaderDialog from "@/components/export/XlsxHeaderDialog.vue";
import { buildXlsxHeaderOverrides, hasXlsxHeaderComments, type XlsxExportOptions, type XlsxHeaderMode } from "@/lib/export/xlsxHeader";
import { isLoadingStructurePreview, showStructureDocCopyDialog, showStructurePreviewDialog, structureDocCopyText, structureDocCopyTitle, structurePreviewDefaultFileName, structurePreviewError, structurePreviewSql, structurePreviewTitle } from "@/components/sidebar/sidebarTreeDialogState";
import {
isLoadingStructurePreview,
showStructureDocCopyDialog,
showStructurePreviewDialog,
structureDocCopyText,
structureDocCopyTitle,
structurePreviewDefaultFileName,
structurePreviewError,
structurePreviewSql,
structurePreviewTitle,
structurePreviewHasOceanBase,
} from "@/components/sidebar/sidebarTreeDialogState";
import type { CsvQuoteMode } from "@/lib/export/csvQuoteMode";
type StructureCopyFormat = "tsv" | "markdown";
@@ -126,28 +138,62 @@ export function useSidebarTreeExportRuntime(options: SidebarTreeExportRuntimeOpt
return sidebarTableDataExportTargets(activeNode.value, connectionStore.treeNodes, options.acceptedSelectionIds() ?? connectionStore.selectedTreeNodeIds);
}
let structureSource: Array<{ ddl: string; databaseType: ReturnType<typeof effectiveDatabaseTypeForConnection> }> = [];
let structureRequestId = 0;
function invalidateStructureRequest() {
structureRequestId++;
structureSource = [];
isLoadingStructurePreview.value = false;
}
watch(
showStructurePreviewDialog,
(visible) => {
if (!visible) invalidateStructureRequest();
},
{ flush: "sync" },
);
if (getCurrentScope()) onScopeDispose(invalidateStructureRequest);
function renderStructurePreview() {
structurePreviewSql.value = joinExportedDdls(structureSource.map(({ ddl, databaseType }) => applyDdlStoragePreference(ddl, databaseType, settingsStore.editorSettings.excludeDdlStorage)));
}
watch(
() => settingsStore.editorSettings.excludeDdlStorage,
() => {
if (showStructurePreviewDialog.value && structureSource.length) renderStructurePreview();
},
);
async function exportStructure() {
const targets = structureExportTargets();
if (!targets.length) return;
const requestId = ++structureRequestId;
const requestSource: typeof structureSource = [];
isLoadingStructurePreview.value = true;
structurePreviewError.value = "";
structurePreviewSql.value = "";
structurePreviewHasOceanBase.value = false;
structureSource = [];
structurePreviewTitle.value = targets.length === 1 ? t("contextMenu.exportStructurePreviewTitle", { name: targets[0]!.label }) : t("contextMenu.exportStructurePreviewTitleMultiple", { count: targets.length });
structurePreviewDefaultFileName.value = targets.length === 1 ? `${targets[0]!.label}.sql` : "structures.sql";
showStructurePreviewDialog.value = true;
try {
const parts: string[] = [];
for (const target of targets) {
await connectionStore.ensureConnected(target.connectionId);
if (requestId !== structureRequestId) return;
const ddl = await api.getTableDdl(target.connectionId, target.database, target.schema || target.database, target.label, tableDdlObjectTypeForNode(target.type), target.catalog, true);
parts.push(ddl.trim());
if (requestId !== structureRequestId) return;
const databaseType = effectiveDatabaseTypeForConnection(connectionStore.getConfig(target.connectionId));
requestSource.push({ ddl, databaseType });
}
structurePreviewSql.value = joinExportedDdls(parts);
structureSource = requestSource;
structurePreviewHasOceanBase.value = requestSource.some(({ databaseType }) => databaseType === "oceanbase-oracle");
renderStructurePreview();
} catch (error: any) {
if (requestId !== structureRequestId) return;
structurePreviewError.value = error?.message || String(error);
console.error("Export structure failed:", error);
} finally {
isLoadingStructurePreview.value = false;
if (requestId === structureRequestId) isLoadingStructurePreview.value = false;
}
}