feat(mongodb): improve sidebar index management
This commit is contained in:
1 parent
22055e081f
commit
8e7b74d0ee
56 files changed
+3558
-246
No files matched your search
+121
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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))));
|
||||
|
||||
Reference in new issue
Block a user