feat(mongodb): improve sidebar index management

This commit is contained in:
onenewcode authored and GitHub committed 2026-08-02 22:45:33 +08:00
1 parent 22055e081f
commit 8e7b74d0ee
56 files changed
+3558 -246

No files matched your search

@@ -0,0 +1,121 @@
import { createPinia, setActivePinia } from "pinia";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ConnectionConfig, MongoCollectionKind, TreeNode } from "@/types/database";
function installLocalStorage() {
const data = new Map<string, string>();
vi.stubGlobal("localStorage", {
getItem: vi.fn((key: string) => data.get(key) ?? null),
setItem: vi.fn((key: string, value: string) => data.set(key, value)),
removeItem: vi.fn((key: string) => data.delete(key)),
});
}
function mongoConnection(): ConnectionConfig {
return {
id: "mongo-1",
name: "MongoDB",
db_type: "mongodb",
host: "127.0.0.1",
port: 27017,
username: "",
password: "",
database: "app",
} as ConnectionConfig;
}
function findNode(nodes: TreeNode[], id: string): TreeNode | undefined {
for (const node of nodes) {
if (node.id === id) return node;
const child = node.children && findNode(node.children, id);
if (child) return child;
}
return undefined;
}
async function setup(kind: MongoCollectionKind) {
const api = {
listIndexes: vi.fn().mockResolvedValue([{ name: "email_1", columns: ["email"], is_primary: false, is_unique: false }]),
};
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
vi.doMock("@/lib/backend/api", () => api);
const { useConnectionStore } = await import("@/stores/connectionStore");
const store = useConnectionStore();
const config = mongoConnection();
const collectionId = `${config.id}:app:reports`;
store.connections = [config];
store.connectedIds.add(config.id);
store.treeNodes = [
{
id: config.id,
label: config.name,
type: "connection",
connectionId: config.id,
isExpanded: true,
children: [
{
id: collectionId,
label: "reports",
type: "mongo-collection",
connectionId: config.id,
database: "app",
meta: { collectionKind: kind },
isExpanded: false,
children: [],
},
],
},
];
return { api, config, store, collectionId };
}
describe("connectionStore MongoDB collection groups", () => {
beforeEach(() => {
vi.resetModules();
vi.unstubAllGlobals();
installLocalStorage();
setActivePinia(createPinia());
});
it("does not expose an Indexes group or issue listIndexes for a view", async () => {
const { api, config, store, collectionId } = await setup("view");
await store.loadTableGroups(config.id, "app", "reports", undefined, collectionId);
const collection = findNode(store.treeNodes, collectionId)!;
expect(collection.children?.some((node) => node.type === "group-indexes")).toBe(false);
// A stale tree node from an earlier metadata load must also remain safe.
const indexGroup: TreeNode = {
id: `${collectionId}:__indexes`,
label: "tree.indexes",
type: "group-indexes",
connectionId: config.id,
database: "app",
tableName: "reports",
meta: { collectionKind: "view" },
isExpanded: false,
children: [],
};
collection.children = [indexGroup];
await store.loadIndexes(config.id, "app", "reports", undefined, indexGroup.id);
expect(api.listIndexes).not.toHaveBeenCalled();
expect(findNode(store.treeNodes, indexGroup.id)).toMatchObject({ children: [], isExpanded: true });
});
it("keeps the Indexes group available for ordinary collections", async () => {
const { api, config, store, collectionId } = await setup("collection");
await store.loadTableGroups(config.id, "app", "reports", undefined, collectionId);
const indexGroup = findNode(store.treeNodes, `${collectionId}:__indexes`)!;
expect(indexGroup.type).toBe("group-indexes");
await store.loadIndexes(config.id, "app", "reports", undefined, indexGroup.id);
expect(api.listIndexes).toHaveBeenCalledOnce();
expect(indexGroup.children?.[0]).toMatchObject({ type: "index", label: "email_1 (email)" });
});
});
@@ -0,0 +1,120 @@
import { createPinia, setActivePinia } from "pinia";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ConnectionConfig } from "@/types/database";
function installLocalStorage() {
const data = new Map<string, string>();
vi.stubGlobal("localStorage", {
getItem: vi.fn((key: string) => data.get(key) ?? null),
setItem: vi.fn((key: string, value: string) => data.set(key, value)),
removeItem: vi.fn((key: string) => data.delete(key)),
});
}
function mongoConnection(): ConnectionConfig {
return {
id: "mongo-legacy-fallback",
name: "MongoDB",
db_type: "mongodb",
driver_profile: "mongodb",
driver_label: "MongoDB",
host: "127.0.0.1",
port: 27017,
username: "",
password: "",
database: "admin",
} as ConnectionConfig;
}
describe("connectionStore MongoDB Legacy fallback", () => {
beforeEach(() => {
vi.resetModules();
vi.unstubAllGlobals();
installLocalStorage();
setActivePinia(createPinia());
});
it("uses the persisted Web fallback profile to hide native-only actions", async () => {
const config = mongoConnection();
const legacyConfig: ConnectionConfig = {
...config,
driver_profile: "mongodb-legacy",
driver_label: "MongoDB (Legacy)",
};
const loadConnections = vi.fn().mockResolvedValue([legacyConfig]);
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
vi.doMock("@/lib/backend/api", () => ({
connectDb: vi.fn().mockResolvedValue(config.id),
loadConnections,
connectionDatabaseInfo: vi.fn().mockRejectedValue(new Error("metadata unavailable")),
connectionIdentifierQuote: vi.fn().mockResolvedValue(undefined),
saveConnections: vi.fn().mockResolvedValue(undefined),
saveSidebarLayout: vi.fn().mockResolvedValue(undefined),
deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined),
}));
const { useConnectionStore } = await import("@/stores/connectionStore");
const store = useConnectionStore();
await store.addConnection(config);
store.connections[0] = {
...store.connections[0],
note: "Updated while connecting",
};
await store.connect(config);
expect(loadConnections).toHaveBeenCalledOnce();
expect(store.getConfig(config.id)?.driver_profile).toBe("mongodb-legacy");
expect(store.getConfig(config.id)?.driver_label).toBe("MongoDB (Legacy)");
expect(store.getConfig(config.id)?.note).toBe("Updated while connecting");
});
it("does not apply a stale fallback after the connection config changes", async () => {
const config = mongoConnection();
const legacyConfig: ConnectionConfig = {
...config,
driver_profile: "mongodb-legacy",
driver_label: "MongoDB (Legacy)",
};
let resolveLoadConnections!: (configs: ConnectionConfig[]) => void;
const loadConnections = vi.fn().mockImplementation(
() =>
new Promise<ConnectionConfig[]>((resolve) => {
resolveLoadConnections = resolve;
}),
);
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
vi.doMock("@/lib/backend/api", () => ({
connectDb: vi.fn().mockResolvedValue(config.id),
loadConnections,
connectionDatabaseInfo: vi.fn().mockRejectedValue(new Error("metadata unavailable")),
connectionIdentifierQuote: vi.fn().mockResolvedValue(undefined),
saveConnections: vi.fn().mockResolvedValue(undefined),
saveSidebarLayout: vi.fn().mockResolvedValue(undefined),
deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined),
}));
const { useConnectionStore } = await import("@/stores/connectionStore");
const store = useConnectionStore();
await store.addConnection(config);
const connectPromise = store.connect(config);
await vi.waitFor(() => expect(loadConnections).toHaveBeenCalledOnce());
const replacement = {
...config,
name: "Replacement MongoDB",
host: "replacement.example.com",
};
await store.updateConnection(replacement);
resolveLoadConnections([legacyConfig]);
await connectPromise;
expect(store.getConfig(config.id)).toMatchObject({
name: "Replacement MongoDB",
host: "replacement.example.com",
driver_profile: "mongodb",
driver_label: "MongoDB",
});
});
});
+28 -8
View File
@@ -99,7 +99,8 @@ import { getTableMetadataCapabilities } from "@/lib/table/tableMetadataCapabilit
import { useSettingsStore } from "@/stores/settingsStore";
import { encodeSqlServerLinkedSchema, parseSqlServerLinkedSchema } from "@/lib/database/sqlServerLinkedServers";
import { inferMongoCompletionFields, type MongoCompletionField } from "@/lib/mongo/mongoCompletion";
import { toMongoCollectionKind } from "@/lib/sidebar/mongoCollectionMutation";
import { isMongoLegacyDriverProfile } from "@/lib/mongo/mongoCapabilities";
import { mongoCollectionKindFromNode, toMongoCollectionKind } from "@/lib/sidebar/mongoCollectionMutation";
import { completionSchemasFromTree, completionTablesFromTree } from "@/lib/metadata/completionTreeIndex";
import { kvRootNodeLabel } from "@/lib/kv/kvRootPresentation";
import { REDIS_SCAN_PAGE_SIZE_DEFAULT } from "@/lib/redis/redisKeyPattern";
@@ -2451,19 +2452,31 @@ export const useConnectionStore = defineStore("connection", () => {
}
async function syncMongoLegacyDriverFallback(connectionId: string, previousConfig: ConnectionConfig) {
if (!isDesktop || previousConfig.db_type !== "mongodb" || previousConfig.driver_profile === MONGO_LEGACY_DRIVER_PROFILE) {
if (previousConfig.db_type !== "mongodb" || isMongoLegacyDriverProfile(previousConfig.driver_profile)) {
return;
}
const expectedConfigFingerprint = connectionConfigFingerprint(previousConfig);
const current = connections.value.find((connection) => connection.id === connectionId);
if (!current || connectionConfigFingerprint(current) !== expectedConfigFingerprint) return;
const savedConnections = await api.loadConnections().catch(() => null);
const savedConfig = savedConnections?.map((connection) => normalizeConnection(connection)).find((connection) => connection.id === connectionId && connection.driver_profile === MONGO_LEGACY_DRIVER_PROFILE);
if (!savedConfig) return;
const savedOriginalIdentity = {
...savedConfig,
driver_profile: previousConfig.driver_profile,
driver_label: previousConfig.driver_label,
};
if (connectionConfigFingerprint(savedOriginalIdentity) !== expectedConfigFingerprint) return;
const idx = connections.value.findIndex((connection) => connection.id === connectionId);
if (idx < 0) return;
if (idx < 0 || connectionConfigFingerprint(connections.value[idx]) !== expectedConfigFingerprint) return;
const nextConnections = [...connections.value];
nextConnections[idx] = {
...savedConfig,
...nextConnections[idx],
driver_profile: MONGO_LEGACY_DRIVER_PROFILE,
driver_label: savedConfig.driver_label || MONGO_LEGACY_DRIVER_LABEL,
};
connections.value = nextConnections;
@@ -4658,7 +4671,8 @@ export const useConnectionStore = defineStore("connection", () => {
children: [],
});
}
if ((node.type === "table" || node.type === "mongo-collection") && !parseSqlServerLinkedSchema(schema)) {
const isMongoView = node.type === "mongo-collection" && mongoCollectionKindFromNode(node) === "view";
if ((node.type === "table" || node.type === "mongo-collection") && !isMongoView && !parseSqlServerLinkedSchema(schema)) {
if (metadataCapabilities.indexes && !isXugu) {
children.push({
id: `${parentId}:__indexes`,
@@ -4669,6 +4683,9 @@ export const useConnectionStore = defineStore("connection", () => {
schema,
catalog,
tableName: table,
// Keep the Mongo collection kind available to index actions so
// views do not offer unsupported index creation or deletion.
meta: node.type === "mongo-collection" ? node.meta : undefined,
isExpanded: false,
children: [],
});
@@ -4827,8 +4844,10 @@ export const useConnectionStore = defineStore("connection", () => {
const load = beginTreeNodeLoad(node);
try {
const metadataCapabilities = getTableMetadataCapabilities(effectiveDatabaseTypeForConnection(getConfig(connectionId)));
if (!metadataCapabilities.indexes) {
const effectiveDbType = effectiveDatabaseTypeForConnection(getConfig(connectionId));
const metadataCapabilities = getTableMetadataCapabilities(effectiveDbType);
const isMongoView = effectiveDbType === "mongodb" && node.type === "group-indexes" && mongoCollectionKindFromNode(node) === "view";
if (!metadataCapabilities.indexes || isMongoView) {
const targetNode = treeNodeLoadTarget(load);
if (!targetNode) return;
setChildren(targetNode, []);
@@ -4839,6 +4858,7 @@ export const useConnectionStore = defineStore("connection", () => {
const indexes = await api.listIndexes(connectionId, database, querySchema, table, catalog);
const targetNode = treeNodeLoadTarget(load);
if (!targetNode) return;
const mongoCollectionKind = effectiveDbType === "mongodb" && targetNode.type === "group-indexes" ? mongoCollectionKindFromNode(targetNode) : undefined;
setChildren(
targetNode,
indexes.map((idx) => ({
@@ -4849,7 +4869,7 @@ export const useConnectionStore = defineStore("connection", () => {
database,
schema,
tableName: table,
meta: idx,
meta: mongoCollectionKind ? { ...idx, collectionKind: mongoCollectionKind } : idx,
})),
);
targetNode.isExpanded = true;
+22 -2
View File
@@ -29,6 +29,7 @@ import {
splitMongoCommandRanges,
type MongoAggregateSafetyOptions,
} from "@/lib/mongo/mongoShellCommand";
import { refreshLoadedMongoIndexes } from "@/lib/mongo/mongoIndexMetadata";
import { redisCommandResultToQueryResult } from "@/lib/redis/redisQueryResult";
import { nextRedisCommandDb } from "@/lib/redis/redisCommandSession";
import { isRedisMutatingCommand } from "@/lib/redis/redisCommandTable";
@@ -684,6 +685,21 @@ export const useQueryStore = defineStore("query", () => {
appendDebugLog(level, `[DBX][executeTabSql:${event}]`, details);
}
async function refreshLoadedMongoIndexesAfterMutation(connectionId: string, database: string, collection: string, traceId: string) {
const connStore = useConnectionStore();
try {
await refreshLoadedMongoIndexes(connStore, { connectionId, database, collection });
} catch (error) {
queryExecutionLog("warn", "mongo-indexes:refresh-failed", {
traceId,
connectionId,
database,
collection,
error: formatError(error),
});
}
}
async function closeResultSession(tab: QueryTab | undefined, preserveSessionId?: string, throwOnError = false) {
const sessionId = tab?.resultSessionId ?? tab?.result?.session_id;
if (!tab || !sessionId || sessionId === preserveSessionId) return;
@@ -3706,8 +3722,12 @@ export const useQueryStore = defineStore("query", () => {
const result = await api.mongoCreateIndex(tab.connectionId, currentDatabase, mongoCommand.collection, mongoCommand.keys, mongoCommand.options);
allResults.push(markQueryResultRowsRaw(annotateMongoResult(mongoCreateIndexToQueryResult(result.name, performance.now() - commandStartedAt))));
} else if (mongoCommand.kind === "dropIndex" || mongoCommand.kind === "dropIndexes") {
const result = await api.mongoDropIndexes(tab.connectionId, currentDatabase, mongoCommand.collection, mongoCommand.kind === "dropIndex" ? mongoCommand.index : mongoCommand.indexes, mongoCommand.kind === "dropIndex");
allResults.push(markQueryResultRowsRaw(annotateMongoResult(mongoDroppedIndexesToQueryResult(result.dropped_names, performance.now() - commandStartedAt))));
try {
const result = await api.mongoDropIndexes(tab.connectionId, currentDatabase, mongoCommand.collection, mongoCommand.kind === "dropIndex" ? mongoCommand.index : mongoCommand.indexes, mongoCommand.kind === "dropIndex");
allResults.push(markQueryResultRowsRaw(annotateMongoResult(mongoDroppedIndexesToQueryResult(result.dropped_names, performance.now() - commandStartedAt, result.failures))));
} finally {
await refreshLoadedMongoIndexesAfterMutation(tab.connectionId, currentDatabase, mongoCommand.collection, traceId);
}
} else if (mongoCommand.kind === "dropCollection") {
await api.mongoDropCollection(tab.connectionId, currentDatabase, mongoCommand.collection);
allResults.push(markQueryResultRowsRaw(annotateMongoResult(mongoWriteToQueryResult(1, performance.now() - commandStartedAt))));