mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
fix(objects): load objects without waiting for slow schema list
This commit is contained in:
@@ -396,6 +396,10 @@ const objectColumnWidths = ref<Record<ObjectBrowserColumnKey, number>>({
|
||||
comment: 260,
|
||||
});
|
||||
const objectBrowserRowsLoadGuard = createObjectBrowserRowsLoadGuard();
|
||||
// schema 列表的失效只跟随上下文(连接/库/schema 变化、卸载),不跟随对象列表加载:
|
||||
// loadObjects() 会推进 objectBrowserRowsLoadGuard 的 epoch,若两者共用一个 epoch,
|
||||
// 与对象列表并行发起的 loadSchemas 会被判定为过期而放弃写入 schema 列表 (#8665)。
|
||||
const schemaListLoadGuard = createObjectBrowserRowsLoadGuard();
|
||||
let stopColumnResize: (() => void) | null = null;
|
||||
let preserveObjectFilterScrollOnce = false;
|
||||
|
||||
@@ -2992,7 +2996,7 @@ async function saveSource() {
|
||||
}
|
||||
|
||||
async function loadSchemas(epoch: number): Promise<boolean> {
|
||||
if (!objectBrowserRowsLoadGuard.isEpochCurrent(epoch)) return false;
|
||||
if (!schemaListLoadGuard.isEpochCurrent(epoch)) return false;
|
||||
if (!needsSchema.value) {
|
||||
schemas.value = [];
|
||||
selectedSchema.value = undefined;
|
||||
@@ -3005,7 +3009,7 @@ async function loadSchemas(epoch: number): Promise<boolean> {
|
||||
const names = filterSchemaNamesForConnection(await api.listSchemas(connectionId, database), props.connection, database, {
|
||||
showSystemSchemas: props.connection.show_system_schemas === true,
|
||||
});
|
||||
if (!objectBrowserRowsLoadGuard.isEpochCurrent(epoch)) return false;
|
||||
if (!schemaListLoadGuard.isEpochCurrent(epoch)) return false;
|
||||
schemas.value = names;
|
||||
if (names.length === 0) {
|
||||
selectedSchema.value = undefined;
|
||||
@@ -3016,7 +3020,7 @@ async function loadSchemas(epoch: number): Promise<boolean> {
|
||||
}
|
||||
return true;
|
||||
} finally {
|
||||
if (objectBrowserRowsLoadGuard.isEpochCurrent(epoch)) loadingSchemas.value = false;
|
||||
if (schemaListLoadGuard.isEpochCurrent(epoch)) loadingSchemas.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3210,17 +3214,30 @@ function normalizeStatisticNumber(value: number | null | undefined): number | nu
|
||||
}
|
||||
|
||||
async function reload(options?: { allowCachedObjects?: boolean; contextEpoch?: number; preserveExistingRows?: boolean }) {
|
||||
const epoch = options?.contextEpoch ?? objectBrowserRowsLoadGuard.invalidate();
|
||||
try {
|
||||
if (!(await loadSchemas(epoch))) return;
|
||||
} catch (e) {
|
||||
const contextEpoch = options?.contextEpoch ?? schemaListLoadGuard.invalidate();
|
||||
// 对象列表只依赖本标签页绑定的 schema,而 schema 列表在部分引擎上可能很慢
|
||||
// (如达梦需要扫描庞大的 SYS.SYSOBJECTS,#8665)。这里让对象列表的加载与
|
||||
// listSchemas 并行进行,避免慢速 schema 列表让标签页长时间停在“没有找到对象”
|
||||
// 的空白态;若 schema 解析结果确实改变了目标 schema,随后按新 schema 重载。
|
||||
const schemaBeforeSchemas = selectedSchema.value;
|
||||
const schemasPromise = loadSchemas(contextEpoch).catch((e) => {
|
||||
// listSchemas 失败(如共享连接上 SET SCHEMA 后的二次元数据请求)时,
|
||||
// loadSchemas 不会触碰 selectedSchema,这里也不清空它:保留 props.schema
|
||||
// 或用户已选值,继续尝试加载对象列表(达梦走用户名回退),避免标签静默
|
||||
// 空白 (#8301)。对象列表若同样失败,loadObjects 自身的 catch 会展示错误。
|
||||
console.warn("[ObjectBrowser] loadSchemas failed, keeping selected schema for", props.connection.id, e);
|
||||
return false;
|
||||
});
|
||||
const parallelObjectLoad = needsSchema.value && selectedSchema.value ? loadObjects({ allowCached: options?.allowCachedObjects, preserveExistingRows: options?.preserveExistingRows }) : undefined;
|
||||
const schemasLoaded = await schemasPromise;
|
||||
await parallelObjectLoad;
|
||||
if (!schemaListLoadGuard.isEpochCurrent(contextEpoch)) return;
|
||||
if (parallelObjectLoad) {
|
||||
if (schemasLoaded && needsSchema.value && selectedSchema.value !== schemaBeforeSchemas) {
|
||||
await loadObjects({ allowCached: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!objectBrowserRowsLoadGuard.isEpochCurrent(epoch)) return;
|
||||
await loadObjects({ allowCached: options?.allowCachedObjects, preserveExistingRows: options?.preserveExistingRows });
|
||||
}
|
||||
|
||||
@@ -3316,13 +3333,15 @@ defineExpose({ focusSearch, refresh, matchesRefreshScope });
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
objectBrowserRowsLoadGuard.invalidate();
|
||||
schemaListLoadGuard.invalidate();
|
||||
stopColumnResize?.();
|
||||
});
|
||||
|
||||
watch(
|
||||
[() => props.connection.id, () => props.database, () => props.schema],
|
||||
async () => {
|
||||
const contextEpoch = objectBrowserRowsLoadGuard.invalidate();
|
||||
objectBrowserRowsLoadGuard.invalidate();
|
||||
const contextEpoch = schemaListLoadGuard.invalidate();
|
||||
selectedSchema.value = props.schema;
|
||||
userHasSelectedFilter.value = false;
|
||||
objectFilter.value = "all";
|
||||
@@ -3341,7 +3360,7 @@ watch(
|
||||
} catch (e) {
|
||||
console.warn("[DBX] ensureConnected failed for", props.connection.id, e);
|
||||
}
|
||||
if (!objectBrowserRowsLoadGuard.isEpochCurrent(contextEpoch)) return;
|
||||
if (!schemaListLoadGuard.isEpochCurrent(contextEpoch)) return;
|
||||
void reload({ allowCachedObjects: true, contextEpoch });
|
||||
},
|
||||
{ immediate: true },
|
||||
|
||||
Vendored
+129
@@ -0,0 +1,129 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { createApp, defineComponent, h, ref, type App } from "vue";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import ObjectBrowser from "@/components/objects/ObjectBrowser.vue";
|
||||
import { invalidateObjectBrowserRowsCache } from "@/lib/table/objectBrowserRowsCache";
|
||||
import type { ConnectionConfig } from "@/types/database";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
listObjects: vi.fn(),
|
||||
listSchemas: vi.fn(),
|
||||
ensureConnected: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/backend/api", () => ({
|
||||
listObjects: (...args: unknown[]) => mocks.listObjects(...args),
|
||||
listSchemas: (...args: unknown[]) => mocks.listSchemas(...args),
|
||||
listObjectStatistics: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
vi.mock("@/stores/connectionStore", () => ({
|
||||
useConnectionStore: () => ({
|
||||
getConfig: () => connection,
|
||||
ensureConnected: mocks.ensureConnected,
|
||||
orderByPinnedTreeNodes: (rows: unknown[]) => rows,
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/stores/queryStore", () => ({ useQueryStore: () => ({}) }));
|
||||
vi.mock("@/stores/settingsStore", () => ({
|
||||
useSettingsStore: () => ({
|
||||
editorSettings: {
|
||||
shortcuts: { refreshData: "F5" },
|
||||
objectBrowserViewMode: "list",
|
||||
objectBrowserShowCheckbox: false,
|
||||
sidebarCopyTableNameSeparator: "newline",
|
||||
sidebarCopyTableNameIncludeSchema: false,
|
||||
},
|
||||
}),
|
||||
}));
|
||||
vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key, locale: ref("en-US") }) }));
|
||||
vi.mock("@/i18n", () => ({ default: { install: () => undefined } }));
|
||||
vi.mock("@/composables/useSqlHighlighter", () => ({ useSqlHighlighter: () => ({ highlight: (sql: string) => sql }) }));
|
||||
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: vi.fn() }) }));
|
||||
vi.mock("vue-virtual-scroller", () => ({
|
||||
RecycleScroller: defineComponent({
|
||||
props: { items: { type: Array, default: () => [] } },
|
||||
setup(props, { slots }) {
|
||||
return () =>
|
||||
h(
|
||||
"div",
|
||||
props.items.map((item) => slots.default?.({ item })),
|
||||
);
|
||||
},
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/components/ui/searchable-select", () => ({ SearchableSelect: { render: () => null } }));
|
||||
vi.mock("@/components/ui/ToolbarOverflowMenu.vue", () => ({ default: { render: () => null } }));
|
||||
vi.mock("@/components/ui/CustomContextMenu.vue", () => ({
|
||||
default: defineComponent({
|
||||
setup(_, { slots }) {
|
||||
return () => slots.default?.({ onContextMenu: () => undefined, isOpen: false });
|
||||
},
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/components/editor/QueryEditor.vue", () => ({ default: { render: () => null } }));
|
||||
vi.mock("@/components/editor/DangerConfirmDialog.vue", () => ({ default: { render: () => null } }));
|
||||
vi.mock("@/components/objects/ProcedureExecutionDialog.vue", () => ({ default: { render: () => null } }));
|
||||
vi.mock("@/components/objects/CustomTypeInfoPanel.vue", () => ({ default: { render: () => null } }));
|
||||
vi.mock("@/components/export/XlsxHeaderDialog.vue", () => ({ default: { render: () => null } }));
|
||||
|
||||
const connection = {
|
||||
id: "dameng-slow-schemas",
|
||||
name: "Dameng",
|
||||
db_type: "dameng",
|
||||
database: "TEST",
|
||||
driver_profile: null,
|
||||
url_params: null,
|
||||
transport_layers: [],
|
||||
} as unknown as ConnectionConfig;
|
||||
|
||||
const mountedApps: Array<{ app: App; host: HTMLElement }> = [];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.listObjects.mockResolvedValue([{ name: "TEST2026", object_type: "TABLE" }]);
|
||||
mocks.ensureConnected.mockResolvedValue(undefined);
|
||||
invalidateObjectBrowserRowsCache({});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const { app, host } of mountedApps.splice(0)) {
|
||||
app.unmount();
|
||||
host.remove();
|
||||
}
|
||||
invalidateObjectBrowserRowsCache({});
|
||||
});
|
||||
|
||||
async function mountBrowser() {
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const app = createApp({ setup: () => () => h(ObjectBrowser, { connection, database: "TEST", schema: "TEST" }) });
|
||||
mountedApps.push({ app, host });
|
||||
app.mount(host);
|
||||
return host;
|
||||
}
|
||||
|
||||
describe("ObjectBrowser with a slow schema list", () => {
|
||||
it("loads the object list without waiting for listSchemas (#8665)", async () => {
|
||||
// listSchemas 一直不返回,模拟达梦扫描庞大 SYS.SYSOBJECTS 的场景。
|
||||
mocks.listSchemas.mockReturnValue(new Promise<string[]>(() => {}));
|
||||
|
||||
const host = await mountBrowser();
|
||||
|
||||
await vi.waitFor(() => expect(host.textContent).toContain("TEST2026"));
|
||||
expect(mocks.listObjects).toHaveBeenCalledWith("dameng-slow-schemas", "TEST", "TEST", undefined, undefined, undefined, undefined, undefined);
|
||||
expect(mocks.listSchemas).toHaveBeenCalledWith("dameng-slow-schemas", "TEST");
|
||||
expect(host.textContent).not.toContain("objects.empty");
|
||||
});
|
||||
|
||||
it("still applies the schema list once a slow listSchemas resolves", async () => {
|
||||
let resolveSchemas: (names: string[]) => void = () => undefined;
|
||||
mocks.listSchemas.mockReturnValue(new Promise<string[]>((resolve) => (resolveSchemas = resolve)));
|
||||
|
||||
const host = await mountBrowser();
|
||||
await vi.waitFor(() => expect(host.textContent).toContain("TEST2026"));
|
||||
|
||||
resolveSchemas(["TEST", "OTHER"]);
|
||||
await vi.waitFor(() => expect(mocks.listObjects).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user