fix(oceanbase): preserve partition DDL and toggle storage options
This commit is contained in:
1 parent
8eba12ea2f
commit
fe0dd02f2b
36 files changed
+870
-125
No files matched your search
+136
-3
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user