fix(sidebar): allow empty database context for hana pagination and filters

This commit is contained in:
lxk955
2026-10-11 08:09:05 +08:00
committed by GitHub
parent 13601ebe1d
commit bb408d7f0e
6 changed files with 332 additions and 10 deletions
@@ -32,6 +32,7 @@ import { createSidebarSearchExpansionState } from "@/lib/sidebar/sidebarSearchEx
import { createSidebarSearchLoadingTracker } from "@/lib/sidebar/sidebarSearchLoadingTracker";
import { isCancelSearchShortcut, isCopySidebarSelectionShortcut, isDisconnectSidebarConnectionShortcut, isEditSidebarConnectionShortcut, isPasteSidebarSelectionShortcut, isViewTableDdlShortcut } from "@/lib/editor/keyboardShortcuts";
import { sidebarNodeSupportsDdlView } from "@/lib/sidebar/sidebarTreeDdlShortcut";
import { hasTreeNodeDatabaseContext } from "@/lib/sidebar/treeNodeContext";
import { objectSourceTargetForTreeNode } from "@/lib/sidebar/treeNodeClick";
import { supportsTypeObjectSource } from "@/lib/database/databaseObjectCapabilities";
import { copyToClipboard } from "@/lib/common/clipboard";
@@ -2150,7 +2151,7 @@ function tableDdlObjectTypeForSidebarNode(type: TreeNodeType): ObjectSourceKind
}
function openSidebarDdl(node: TreeNode) {
if (!node.connectionId || !node.database) return;
if (!node.connectionId || !hasTreeNodeDatabaseContext(node)) return;
beginSidebarAction();
sidebarDdlTarget.value = createSidebarActionTarget(node);
sidebarDdlOpen.value = true;
@@ -2172,7 +2173,7 @@ function openSidebarElasticsearchIndexMetadata(node: TreeNode, kind: Elasticsear
}
function openSidebarObjectSource(node: TreeNode, initialEditing: boolean) {
if (!node.connectionId || !node.database || !objectSourceTargetForTreeNode(node)) return;
if (!node.connectionId || !hasTreeNodeDatabaseContext(node) || !objectSourceTargetForTreeNode(node)) return;
// TYPE/TYPE_BODY only have a source implementation on Xugu; PostgreSQL-family
// connections list user-defined types without a CREATE TYPE getter this cycle.
if ((node.type === "type" || node.type === "type-body") && !supportsTypeObjectSource(store.getConfig(node.connectionId)?.db_type)) return;
@@ -2189,7 +2190,7 @@ function openSidebarSettings(initialTab: string) {
}
function openSidebarProcedure(node: TreeNode) {
if (node.type !== "procedure" || !node.connectionId || !node.database) return;
if (node.type !== "procedure" || !node.connectionId || !hasTreeNodeDatabaseContext(node)) return;
beginSidebarAction();
sidebarProcedureTarget.value = createSidebarActionTarget(node);
sidebarProcedureOpen.value = true;
@@ -2235,7 +2236,7 @@ function openSidebarVisibleNacosNamespaces(node: TreeNode) {
}
function tableNameFilterScopeForNode(node: TreeNode): string | null {
if (!node.connectionId || !node.database) return null;
if (!node.connectionId || !hasTreeNodeDatabaseContext(node)) return null;
return store.tableNameFilterScopeKey({
connectionId: node.connectionId,
database: node.database,
@@ -93,6 +93,7 @@ import { isLoginUserSchemaNode } from "@/lib/sidebar/loginUserNode";
import { sidebarTreeContextKey } from "@/lib/sidebar/sidebarTreeContext";
import { connectionCanConfigureSidebarVisibleDatabases } from "@/lib/sidebar/sidebarVisibleFilterMenu";
import { supportsSidebarObjectNameFilter } from "@/lib/sidebar/sidebarObjectNameFilter";
import { hasTreeNodeDatabaseContext } from "@/lib/sidebar/treeNodeContext";
import { isWindows } from "@/lib/backend/platform";
import { flattenTree } from "@/composables/useFlatTree";
import { productionContextForDatabase } from "@/lib/database/productionSafety";
@@ -480,7 +481,7 @@ function visibleLabel(node: TreeNode): string {
}
function hasActiveObjectNameFilter(node: TreeNode): boolean {
if (!supportsSidebarObjectNameFilter(node) || !node.connectionId || !node.database) return false;
if (!supportsSidebarObjectNameFilter(node) || !node.connectionId || !hasTreeNodeDatabaseContext(node)) return false;
const filter = connectionStore.tableNameFilterForScope({
connectionId: node.connectionId,
database: node.database,
@@ -0,0 +1,110 @@
// @vitest-environment happy-dom
import { createApp, defineComponent, h, nextTick, type App } from "vue";
import { afterEach, describe, expect, it, vi } from "vitest";
import i18n from "@/i18n";
import TreeItem from "@/components/sidebar/TreeItem.vue";
import { createSidebarTreeRuntime, sidebarTreeRuntimeKey, type SidebarTreeRuntimeHost } from "@/lib/sidebar/sidebarTreeRuntime";
import type { TreeNode } from "@/types/database";
const filterState = {
includePatterns: ["vw_%"],
excludePatterns: [],
};
const connectionStore = {
activeConnectionId: "hana-1",
connectedIds: new Set(["hana-1"]),
connectingIds: new Set<string>(),
connectionMultiSelectActive: false,
connections: [],
getConfig: () => ({ id: "hana-1", db_type: "saphana" }),
isDefaultDatabase: () => false,
isDefaultSchema: () => false,
isPinnedTreeNodeReorderTarget: () => false,
isTreeNodeChildrenLoaded: () => false,
isTreeNodePinned: () => false,
selectedTreeNodeId: null as string | null,
selectedTreeNodeIds: [] as string[],
selectedTreeNodeIdsSet: new Set<string>(),
sidebarTableSearchQueries: {},
tableNameFilterForScope: vi.fn((_scope: any) => filterState),
treeNodes: [],
treeSelectionAnchorId: null as string | null,
};
vi.mock("@/stores/connectionStore", () => ({ useConnectionStore: () => connectionStore }));
vi.mock("@/stores/queryStore", () => ({ useQueryStore: () => ({ openDatabaseKeys: new Set<string>() }) }));
vi.mock("@/stores/settingsStore", () => ({
useSettingsStore: () => ({
editorSettings: {
shortcuts: { openDataInNewTab: "" },
sidebarActivation: "double",
sidebarAllowHorizontalScroll: false,
sidebarHiddenTablePrefixes: [],
sidebarObjectInfoMode: "none",
},
}),
}));
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: vi.fn() }) }));
const mountedApps: App[] = [];
function runtimeHost(): SidebarTreeRuntimeHost {
return {
buildContextMenu: vi.fn(() => []),
handleRowClick: vi.fn(),
handleRowDoubleClick: vi.fn(),
handleRowKeydown: vi.fn(),
openPrimaryVisibleFilter: vi.fn(),
openDataInNewTab: vi.fn(),
requestPaste: vi.fn(() => false),
toggleNode: vi.fn(),
};
}
async function mountTreeItem(node: TreeNode) {
const container = document.createElement("div");
document.body.append(container);
const runtime = createSidebarTreeRuntime();
runtime.bindHost(runtimeHost());
const app = createApp(defineComponent({ setup: () => () => h(TreeItem, { node, depth: 2 }) }));
mountedApps.push(app);
app.use(i18n);
app.provide(sidebarTreeRuntimeKey, runtime);
app.mount(container);
await nextTick();
return container;
}
afterEach(() => {
for (const app of mountedApps.splice(0)) app.unmount();
document.body.replaceChildren();
});
describe("TreeItem empty database filter badge", () => {
it("renders active filter indicator for catalogless group node with empty database", async () => {
const node: TreeNode = {
id: "hana-1:::SYS:group-views",
label: "tree.views",
type: "group-views",
connectionId: "hana-1",
database: "",
schema: "SYS",
objectCount: 12,
children: [],
};
const container = await mountTreeItem(node);
expect(connectionStore.tableNameFilterForScope).toHaveBeenCalledWith(
expect.objectContaining({
connectionId: "hana-1",
database: "",
schema: "SYS",
nodeKind: "group-views",
}),
);
expect(container.textContent).toContain("12");
expect(container.textContent).toContain("filtered");
});
});
@@ -4693,4 +4693,109 @@ describe("connectionStore metadata loading", () => {
expect(typeNode.children?.map((child) => child.label)).toEqual(["published"]);
expect(store.isTreeNodeChildrenLoaded(typeNode.id)).toBe(true);
});
it("loads more object group children for catalogless databases with empty database string", async () => {
const firstPageTables = Array.from({ length: 201 }, (_, i) => ({
name: `t_${String(i + 1).padStart(4, "0")}`,
table_type: "BASE TABLE",
comment: null,
}));
const secondPageTables = [
{
name: "t_0201",
table_type: "BASE TABLE",
comment: null,
},
];
const listTables = vi.fn(async (...args: unknown[]) => {
const offset = args[5] as number;
if (offset === 0) return firstPageTables;
return secondPageTables;
});
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
vi.doMock("@/lib/backend/api", () => ({
checkConnectionHealth: vi.fn().mockResolvedValue(undefined),
deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined),
listTables,
loadSchemaCache: vi.fn().mockResolvedValue(null),
saveSchemaCache: vi.fn().mockResolvedValue(undefined),
saveConnections: vi.fn().mockResolvedValue(undefined),
saveSidebarLayout: vi.fn().mockResolvedValue(undefined),
}));
const { useConnectionStore } = await import("@/stores/connectionStore");
const { useSettingsStore } = await import("@/stores/settingsStore");
const store = useConnectionStore();
const settingsStore = useSettingsStore();
settingsStore.editorSettings.sidebarObjectDisplay = "grouped";
settingsStore.desktopSettings.sidebar_table_page_size = 200;
const connection: ConnectionConfig = {
id: "hana-1",
name: "SAP HANA",
db_type: "saphana",
host: "127.0.0.1",
port: 39015,
username: "SYSTEM",
password: "",
database: "",
} as ConnectionConfig;
const tablesGroup: TreeNode = {
id: "hana-1:::APP:__tables",
label: "tree.tables",
type: "group-tables",
connectionId: connection.id,
database: "",
schema: "APP",
isExpanded: false,
children: [],
};
const schemaNode: TreeNode = {
id: "hana-1:::APP",
label: "APP",
type: "schema",
connectionId: connection.id,
database: "",
schema: "APP",
isExpanded: true,
children: [tablesGroup],
};
const databaseNode: TreeNode = {
id: "hana-1::",
label: "tree.defaultDatabase",
type: "database",
connectionId: connection.id,
database: "",
isExpanded: true,
children: [schemaNode],
};
store.connections = [connection];
store.connectedIds.add(connection.id);
store.treeNodes = [
{
id: connection.id,
label: connection.name,
type: "connection",
connectionId: connection.id,
isExpanded: true,
children: [databaseNode],
},
];
await store.loadObjectGroupChildren(tablesGroup);
const loadMoreNode = tablesGroup.children?.at(-1);
expect(loadMoreNode?.type).toBe("load-more");
await store.loadMoreObjectGroupChildren(loadMoreNode!);
expect(listTables).toHaveBeenCalledTimes(2);
expect(listTables.mock.calls[1][1]).toBe("");
expect(listTables.mock.calls[1][2]).toBe("APP");
expect(listTables.mock.calls[1][5]).toBe(200);
const labels = (tablesGroup.children ?? []).filter((child) => child.type === "table").map((child) => child.label);
expect(labels).toContain("t_0201");
});
});
+5 -5
View File
@@ -3142,7 +3142,7 @@ export const useConnectionStore = defineStore("connection", () => {
}
function sidebarTableSearchTreeCacheKey(parent: TreeNode): string | null {
if (!parent.connectionId || !parent.database) return null;
if (!parent.connectionId || !hasTreeNodeDatabaseContext(parent)) return null;
if (parent.type === "group-tables") return objectGroupCacheKey(parent);
if (parent.type !== "database" && parent.type !== "schema" && parent.type !== "linked-server-schema") return null;
const simpleObjectDisplay = useSettingsStore().editorSettings.sidebarObjectDisplay === "simple";
@@ -3183,7 +3183,7 @@ export const useConnectionStore = defineStore("connection", () => {
const sidebarTableSearchIndexManifestCacheKey = "dbx:sidebar-table-search-index-manifest-v1";
function sidebarTableSearchIndexManifestEntry(parent: TreeNode, cacheKey: string): TableSearchIndexManifestEntry | null {
if (!parent.connectionId || !parent.database || !parent.type) return null;
if (!parent.connectionId || !hasTreeNodeDatabaseContext(parent) || !parent.type) return null;
const identity: SidebarRegexScopeIdentity = {
connectionId: parent.connectionId,
database: parent.database,
@@ -3278,7 +3278,7 @@ export const useConnectionStore = defineStore("connection", () => {
};
function sidebarTableSearchIndexScopeForNode(parent: TreeNode): SidebarTableSearchIndexScope | null {
if (!parent.connectionId || !parent.database) return null;
if (!parent.connectionId || !hasTreeNodeDatabaseContext(parent)) return null;
const cacheKey = sidebarTableSearchIndexCacheKey(parent);
if (!cacheKey) return null;
return {
@@ -6929,7 +6929,7 @@ export const useConnectionStore = defineStore("connection", () => {
load = reclaimTreeNodeLoad(load, node);
if (parent.type === "database" || parent.type === "schema" || parent.type === "linked-server-schema") {
const parentDatabase = parent.database;
if (!parentDatabase) return;
if (!hasTreeNodeDatabaseContext(parent)) return;
const config = getConfig(parentConnectionId);
const querySchema = connectionObjectTreeQuerySchema(config, parentDatabase, parent.schema);
const effectiveSchema = connectionObjectTreeNodeSchema(config, parentDatabase, parent.schema);
@@ -6970,7 +6970,7 @@ export const useConnectionStore = defineStore("connection", () => {
const config = getConfig(parentConnectionId);
const parentDatabase = parent.database;
if (!parentDatabase) return;
if (!hasTreeNodeDatabaseContext(parent)) return;
const querySchema = connectionObjectTreeQuerySchema(config, parentDatabase, parent.schema);
const effectiveSchema = connectionObjectTreeNodeSchema(config, parentDatabase, parent.schema);
const wantsOnlyTablesOrViews = objectTypes.every((objectType) => objectType === "TABLE" || objectType === "VIEW" || objectType === "MATERIALIZED_VIEW");
+105
View File
@@ -138,3 +138,108 @@ test("object-group filters keep table, view, procedure, and function scopes isol
expect(liveGroup("group-functions").children?.map((child) => child.label)).toEqual(["fn_get_user", "internal_hash"]);
expect(listObjects.mock.calls.at(-1)).toEqual([currentConnection.id, "app", "public", ["FUNCTION"], undefined, 1001, 0]);
}, 15000);
test("supports object-group filters on catalogless databases with empty database string", async () => {
const listTables = vi.fn(async (...args: unknown[]) => {
const objectTypes = args[6] as string[] | undefined;
const filter = args[8] as { includePatterns: string[]; excludePatterns: string[] } | undefined;
if (objectTypes?.[0] === "VIEW") {
if (filter?.includePatterns.includes("vw_%")) return [{ name: "vw_orders", table_type: "VIEW", comment: null }];
return [
{ name: "vw_orders", table_type: "VIEW", comment: null },
{ name: "other_view", table_type: "VIEW", comment: null },
];
}
return [];
});
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
vi.doMock("@/lib/backend/api", () => ({
checkConnectionHealth: vi.fn().mockResolvedValue(undefined),
deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined),
listObjects: vi.fn().mockResolvedValue([]),
listTables,
loadSchemaCache: vi.fn().mockResolvedValue(null),
saveSchemaCache: vi.fn().mockResolvedValue(undefined),
saveConnections: vi.fn().mockResolvedValue(undefined),
saveSidebarLayout: vi.fn().mockResolvedValue(undefined),
}));
const { useConnectionStore } = await import("../../apps/desktop/src/stores/connectionStore.ts");
const store = useConnectionStore();
const hanaConnection: ConnectionConfig = {
id: "hana-1",
name: "SAP HANA",
db_type: "saphana",
host: "127.0.0.1",
port: 39015,
username: "SYSTEM",
password: "",
database: "",
} as ConnectionConfig;
const viewsGroup: TreeNode = {
id: "hana-1:::SYS:__views",
label: "tree.views",
type: "group-views",
connectionId: hanaConnection.id,
database: "",
schema: "SYS",
isExpanded: false,
children: [],
};
const schemaNode: TreeNode = {
id: "hana-1:::SYS",
label: "SYS",
type: "schema",
connectionId: hanaConnection.id,
database: "",
schema: "SYS",
isExpanded: true,
children: [viewsGroup],
};
const defaultDatabaseNode: TreeNode = {
id: "hana-1::",
label: "tree.defaultDatabase",
type: "database",
connectionId: hanaConnection.id,
database: "",
isExpanded: true,
children: [schemaNode],
};
store.connections = [hanaConnection];
store.connectedIds.add(hanaConnection.id);
store.treeNodes = [
{
id: hanaConnection.id,
label: hanaConnection.name,
type: "connection",
connectionId: hanaConnection.id,
isExpanded: true,
children: [defaultDatabaseNode],
},
];
const scopeKey = store.tableNameFilterScopeKey({
connectionId: hanaConnection.id,
database: viewsGroup.database,
schema: viewsGroup.schema,
nodeKind: viewsGroup.type,
});
expect(scopeKey).toBeTruthy();
const revision = store.setSidebarTableNameFilter(scopeKey, { includePatterns: ["vw_%"], excludePatterns: [] });
await store.refreshTreeNodeForTableNameFilter(viewsGroup, scopeKey, revision);
expect(viewsGroup.children?.map((child) => child.label)).toEqual(["vw_orders"]);
expect(store.tableNameFilterForScope({
connectionId: hanaConnection.id,
database: "",
schema: "SYS",
nodeKind: "group-views",
})).toEqual({
includePatterns: ["vw_%"],
excludePatterns: [],
});
});