mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
fix(sidebar): allow empty database context for hana pagination and filters
This commit is contained in:
@@ -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,
|
||||
|
||||
+110
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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: [],
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user