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

@@ -140,6 +140,7 @@ describe("database namespace creation", () => {
expect(connectionNamespaceCreationTarget({ db_type: "sqlite" })).toBe("attach");
expect(connectionNamespaceCreationTarget({ db_type: "mongodb" })).toBe("special");
expect(connectionNamespaceCreationTarget({ db_type: "mongodb", driver_profile: "mongodb-legacy" })).toBeNull();
expect(connectionNamespaceCreationTarget({ db_type: "mongodb", driver_profile: "legacy" })).toBeNull();
});
it("hides persistent SQLite attachment for memory and SQLCipher connections", () => {
+2 -1
View File
@@ -99,6 +99,7 @@ import type {
DocumentQueryResult,
MongoDocumentResult,
MongoCollectionStatsResult,
MongoDropIndexesResult,
MongoGridFsBucketInfo,
HistoryEntry,
HistorySearchRequest,
@@ -3105,7 +3106,7 @@ export async function mongoCreateIndex(connectionId: string, database: string, c
});
}
export async function mongoDropIndexes(connectionId: string, database: string, collection: string, indexesJson?: string, single = false): Promise<{ dropped_names: string[]; affected_rows: number }> {
export async function mongoDropIndexes(connectionId: string, database: string, collection: string, indexesJson?: string, single = false): Promise<MongoDropIndexesResult> {
return post("/api/mongo/drop-indexes", {
connectionId,
database,
+12 -1
View File
@@ -2796,6 +2796,17 @@ export interface MongoCollectionStatsResult {
nindexes: unknown;
}
export interface MongoDropIndexFailure {
name: string;
message: string;
}
export interface MongoDropIndexesResult {
dropped_names: string[];
affected_rows: number;
failures?: MongoDropIndexFailure[];
}
export interface MongoGridFsFileInfo {
id: string;
filename?: string;
@@ -3043,7 +3054,7 @@ export async function mongoCreateIndex(connectionId: string, database: string, c
});
}
export async function mongoDropIndexes(connectionId: string, database: string, collection: string, indexesJson?: string, single = false): Promise<{ dropped_names: string[]; affected_rows: number }> {
export async function mongoDropIndexes(connectionId: string, database: string, collection: string, indexesJson?: string, single = false): Promise<MongoDropIndexesResult> {
return invoke("mongo_drop_indexes", {
connectionId,
database,
@@ -1,4 +1,5 @@
import type { ConnectionConfig, DatabaseType, TreeNodeType } from "@/types/database";
import { isMongoLegacyDriverProfile } from "@/lib/mongo/mongoCapabilities";
export type DatabaseNamespaceCreationTarget = "database" | "schema" | "attach" | "special";
@@ -87,7 +88,7 @@ export const DATABASE_NAMESPACE_CREATION_MATRIX = {
export function connectionNamespaceCreationTarget(connection: CreationConnection): ConnectionCreationTarget | null {
if (!connection || connection.read_only) return null;
if (connection.db_type === "mongodb" && connection.driver_profile === "mongodb-legacy") return null;
if (connection.db_type === "mongodb" && isMongoLegacyDriverProfile(connection.driver_profile)) return null;
if (connection.db_type === "sqlite" && (connection.host?.trim().toLowerCase() === ":memory:" || Boolean(connection.password))) {
return null;
}
@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import { isMongoLegacyDriverProfile, mongoCollectionSupportsIndexes, supportsMongoAllDriverMutations, supportsMongoIndexMutations, supportsNativeMongoDriverMutations } from "@/lib/mongo/mongoCapabilities";
describe("MongoDB driver mutation capabilities", () => {
it("keeps index and drop mutations available through both native and Legacy drivers", () => {
expect(supportsMongoAllDriverMutations({ db_type: "mongodb", driver_profile: "mongodb" })).toBe(true);
expect(supportsMongoAllDriverMutations({ db_type: "mongodb", driver_profile: "mongodb-legacy" })).toBe(true);
});
it("does not offer mutation actions on a read-only MongoDB connection", () => {
const readOnly = { db_type: "mongodb", driver_profile: "mongodb-legacy", read_only: true } as const;
expect(supportsMongoAllDriverMutations(readOnly)).toBe(false);
expect(supportsMongoIndexMutations(readOnly, "collection")).toBe(false);
expect(supportsNativeMongoDriverMutations({ ...readOnly, driver_profile: "mongodb" })).toBe(false);
});
it("keeps index metadata visible on read-only collections", () => {
expect(mongoCollectionSupportsIndexes("collection")).toBe(true);
expect(mongoCollectionSupportsIndexes("timeseries")).toBe(true);
expect(mongoCollectionSupportsIndexes(undefined)).toBe(true);
expect(mongoCollectionSupportsIndexes(" VIEW ")).toBe(false);
});
it("allows index mutations for collections and time-series collections, but not views", () => {
const native = { db_type: "mongodb", driver_profile: "mongodb" } as const;
const legacy = { db_type: "mongodb", driver_profile: "mongodb-legacy" } as const;
expect(supportsMongoIndexMutations(native, "collection")).toBe(true);
expect(supportsMongoIndexMutations(legacy, "timeseries")).toBe(true);
expect(supportsMongoIndexMutations(legacy)).toBe(true);
expect(supportsMongoIndexMutations(native, " VIEW ")).toBe(false);
});
it("reserves native-only mutations for the native MongoDB driver", () => {
expect(supportsNativeMongoDriverMutations({ db_type: "mongodb", driver_profile: "mongodb" })).toBe(true);
expect(supportsNativeMongoDriverMutations({ db_type: "mongodb", driver_profile: undefined })).toBe(true);
expect(supportsNativeMongoDriverMutations({ db_type: "mongodb", driver_profile: "mongodb-legacy" })).toBe(false);
expect(supportsNativeMongoDriverMutations({ db_type: "mongodb", driver_profile: "legacy" })).toBe(false);
expect(supportsNativeMongoDriverMutations({ db_type: "mongodb", driver_profile: "MongoDB_Legacy" })).toBe(false);
});
it("recognizes historical Legacy profile spellings", () => {
expect(isMongoLegacyDriverProfile("mongodb-legacy")).toBe(true);
expect(isMongoLegacyDriverProfile(" mongodb_legacy ")).toBe(true);
expect(isMongoLegacyDriverProfile("legacy")).toBe(true);
expect(isMongoLegacyDriverProfile("mongodb")).toBe(false);
});
it("does not grant MongoDB mutations to another connection type", () => {
expect(supportsMongoAllDriverMutations({ db_type: "postgres", driver_profile: undefined })).toBe(false);
expect(supportsNativeMongoDriverMutations(undefined)).toBe(false);
});
});
@@ -0,0 +1,51 @@
import { describe, expect, it, vi } from "vitest";
import type { TreeNode } from "@/types/database";
import { findLoadedMongoIndexesGroup, refreshLoadedMongoIndexes } from "../mongoIndexMetadata";
const scope = { connectionId: "conn-1", database: "app", collection: "users" };
function indexesGroup(isExpanded = false): TreeNode {
return {
id: "conn-1:app:users:__indexes",
label: "tree.indexes",
type: "group-indexes",
connectionId: "conn-1",
database: "app",
tableName: "users",
isExpanded,
children: [],
};
}
describe("Mongo index metadata refresh", () => {
it("finds groups retained under hidden tree children", () => {
const group = indexesGroup();
const root = { id: "root", label: "root", type: "connection", hiddenChildren: [group] } as TreeNode;
expect(findLoadedMongoIndexesGroup([root], scope)).toBe(group);
});
it("refreshes a loaded group without expanding a collapsed tree node", async () => {
const group = indexesGroup(false);
const loadIndexes = vi.fn(async () => {
group.isExpanded = true;
});
await expect(refreshLoadedMongoIndexes({ treeNodes: [group], loadIndexes }, scope)).resolves.toBe(true);
expect(loadIndexes).toHaveBeenCalledWith("conn-1", "app", "users", undefined, group.id, undefined);
expect(group.isExpanded).toBe(false);
});
it("restores expansion state and preserves a refresh failure", async () => {
const group = indexesGroup(false);
const failure = new Error("metadata unavailable");
const loadIndexes = vi.fn(async () => {
group.isExpanded = true;
throw failure;
});
await expect(refreshLoadedMongoIndexes({ treeNodes: [group], loadIndexes }, scope)).rejects.toBe(failure);
expect(group.isExpanded).toBe(false);
});
});
@@ -0,0 +1,28 @@
import type { ConnectionConfig } from "@/types/database";
type MongoConnectionProfile = Pick<ConnectionConfig, "db_type" | "driver_profile" | "read_only">;
/** Accept historical Legacy profile spellings while persisted configs are normalized. */
export function isMongoLegacyDriverProfile(driverProfile?: string): boolean {
const profile = driverProfile?.trim().toLowerCase();
return profile === "mongodb-legacy" || profile === "mongodb_legacy" || profile === "legacy";
}
/** Capabilities implemented by both the native driver and the Legacy Agent. */
export function supportsMongoAllDriverMutations(connection?: MongoConnectionProfile): boolean {
return connection?.db_type === "mongodb" && !connection.read_only;
}
/** MongoDB views do not own indexes; collections and time-series collections do. */
export function mongoCollectionSupportsIndexes(collectionKind?: string): boolean {
return collectionKind?.trim().toLowerCase() !== "view";
}
export function supportsMongoIndexMutations(connection?: MongoConnectionProfile, collectionKind?: string): boolean {
return supportsMongoAllDriverMutations(connection) && mongoCollectionSupportsIndexes(collectionKind);
}
/** Capabilities which require the native Rust MongoDB driver. */
export function supportsNativeMongoDriverMutations(connection?: MongoConnectionProfile): boolean {
return supportsMongoAllDriverMutations(connection) && !isMongoLegacyDriverProfile(connection?.driver_profile);
}
@@ -0,0 +1,40 @@
import type { TreeNode } from "@/types/database";
type MongoIndexMetadataStore = {
treeNodes: TreeNode[];
loadIndexes: (connectionId: string, database: string, table: string, schema?: string, nodeId?: string, catalog?: string) => Promise<void>;
};
export type MongoIndexMetadataScope = {
connectionId: string;
database: string;
collection: string;
};
export function findLoadedMongoIndexesGroup(nodes: readonly TreeNode[], scope: MongoIndexMetadataScope): TreeNode | undefined {
const pending = [...nodes];
const visited = new Set<TreeNode>();
while (pending.length > 0) {
const node = pending.pop()!;
if (visited.has(node)) continue;
visited.add(node);
if (node.type === "group-indexes" && node.connectionId === scope.connectionId && node.database === scope.database && node.tableName === scope.collection) return node;
pending.push(...(node.children ?? []), ...(node.hiddenChildren ?? []));
}
return undefined;
}
/** Refresh an existing sidebar index group without changing its expanded state. */
export async function refreshLoadedMongoIndexes(store: MongoIndexMetadataStore, scope: MongoIndexMetadataScope): Promise<boolean> {
const group = findLoadedMongoIndexesGroup(store.treeNodes, scope);
if (!group) return false;
const wasExpanded = group.isExpanded;
try {
await store.loadIndexes(scope.connectionId, scope.database, scope.collection, group.schema, group.id, group.catalog);
} finally {
const currentGroup = findLoadedMongoIndexesGroup(store.treeNodes, scope);
if (currentGroup) currentGroup.isExpanded = wasExpanded;
}
return true;
}
@@ -756,7 +756,15 @@ export function mongoCreateIndexToQueryResult(name: string, executionTimeMs: num
};
}
export function mongoDroppedIndexesToQueryResult(names: string[], executionTimeMs: number): QueryResult {
export function mongoDroppedIndexesToQueryResult(names: string[], executionTimeMs: number, failures: Array<{ name: string; message: string }> = []): QueryResult {
if (failures.length > 0) {
return {
columns: ["name", "status", "message"],
rows: [...names.map((name) => [name, "dropped", null] as [string, string, null]), ...failures.map((failure) => [failure.name, "failed", failure.message])],
affected_rows: names.length,
execution_time_ms: Math.max(0, Math.round(executionTimeMs)),
};
}
return {
columns: ["name"],
rows: names.map((name) => [name]),
@@ -1,5 +1,23 @@
import { describe, expect, it } from "vitest";
import { isRenamableMongoCollection, mongoCollectionKindFromNode, mongoDropAllIndexesPreview, mongoDropCollectionPreview, mongoDropIndexPreview, mongoRenameCollectionPreview, toMongoCollectionKind } from "../mongoCollectionMutation";
import {
buildMongoCreateIndexRequest,
isProtectedMongoIndex,
isRenamableMongoCollection,
mongoCollectionKindFromNode,
mongoCollectionTableTypeFromNode,
mongoCreateIndexPreview,
mongoDropCollectionPreview,
mongoDropAllIndexesPreview,
mongoDropIndexFailureCount,
mongoDropIndexPreview,
mongoRenameCollectionPreview,
toMongoCollectionKind,
type MongoCreateIndexForm,
} from "../mongoCollectionMutation";
function indexForm(fields: MongoCreateIndexForm["fields"], options: Partial<Omit<MongoCreateIndexForm, "fields">> = {}): MongoCreateIndexForm {
return { name: "", unique: false, sparse: false, ...options, fields };
}
describe("isRenamableMongoCollection", () => {
it("allows ordinary collections and defaults", () => {
@@ -21,6 +39,12 @@ describe("mongoCollectionKindFromNode", () => {
expect(mongoCollectionKindFromNode({ meta: { collectionKind: "collection" } })).toBe("collection");
expect(mongoCollectionKindFromNode({})).toBe("collection");
});
it("maps collection kinds to data-tab table types", () => {
expect(mongoCollectionTableTypeFromNode({ meta: { collectionKind: "collection" } })).toBe("TABLE");
expect(mongoCollectionTableTypeFromNode({ meta: { collectionKind: "view" } })).toBe("VIEW");
expect(mongoCollectionTableTypeFromNode({ meta: { collectionKind: "timeseries" } })).toBe("TIMESERIES");
});
});
describe("toMongoCollectionKind", () => {
@@ -32,6 +56,14 @@ describe("toMongoCollectionKind", () => {
});
});
describe("isProtectedMongoIndex", () => {
it("protects the default index by name or primary metadata", () => {
expect(isProtectedMongoIndex({ name: "_id_", is_primary: false })).toBe(true);
expect(isProtectedMongoIndex({ name: "unexpected", is_primary: true })).toBe(true);
expect(isProtectedMongoIndex({ name: "email_1", is_primary: false })).toBe(false);
});
});
describe("mongo shell previews", () => {
it("preserves identifier whitespace in rename preview", () => {
expect(mongoRenameCollectionPreview("app", " users ", " renamed ")).toBe('db.getSiblingDB("app").getCollection(" users ").renameCollection(" renamed ")');
@@ -42,4 +74,66 @@ describe("mongo shell previews", () => {
expect(mongoDropIndexPreview("app", "users", "idx_name")).toBe('db.getSiblingDB("app").getCollection("users").dropIndex("idx_name")');
expect(mongoDropAllIndexesPreview("app", "users")).toBe('db.getSiblingDB("app").getCollection("users").dropIndexes()');
});
it("counts per-index failures in partial batch results", () => {
expect(mongoDropIndexFailureCount({})).toBe(0);
expect(mongoDropIndexFailureCount({ failures: [{ name: "missing_1", message: "index not found" }] })).toBe(1);
});
it("builds a create-index request and shell preview from the visual form", () => {
const request = buildMongoCreateIndexRequest(
indexForm(
[
{ id: 1, path: "email", type: "1" },
{ id: 2, path: "createdAt", type: "-1" },
],
{ name: "email_created_at", unique: true, sparse: true },
),
);
expect(request).toMatchObject({
valid: true,
keysJson: '{"email":1,"createdAt":-1}',
optionsJson: '{"name":"email_created_at","unique":true,"sparse":true}',
});
if (!request.valid) throw new Error("expected valid index form");
expect(mongoCreateIndexPreview("app", "users", request.keysJson, request.optionsJson)).toBe('db.getSiblingDB("app").getCollection("users").createIndex({"email":1,"createdAt":-1}, {"name":"email_created_at","unique":true,"sparse":true})');
});
it("keeps visual compound-field order, including integer-like names", () => {
const request = buildMongoCreateIndexRequest(
indexForm([
{ id: 1, path: "10", type: "1" },
{ id: 2, path: "2", type: "-1" },
]),
);
if (!request.valid) throw new Error("expected valid index form");
expect(request.optionsJson).toBeUndefined();
expect(mongoCreateIndexPreview("app", "events", request.keysJson, request.optionsJson)).toBe('db.getSiblingDB("app").getCollection("events").createIndex({"10":1,"2":-1})');
});
it("serializes MongoDB-specific key types without exposing JSON inputs", () => {
const request = buildMongoCreateIndexRequest(
indexForm([
{ id: 1, path: "content", type: "text" },
{ id: 2, path: "location", type: "2dsphere" },
]),
);
expect(request).toEqual({ valid: true, keysJson: '{"content":"text","location":"2dsphere"}', optionsJson: undefined });
});
it("requires every field and rejects duplicate field paths", () => {
expect(buildMongoCreateIndexRequest(indexForm([]))).toEqual({ valid: false, error: "field-required" });
expect(buildMongoCreateIndexRequest(indexForm([{ id: 1, path: " ", type: "1" }]))).toEqual({ valid: false, error: "field-required" });
expect(
buildMongoCreateIndexRequest(
indexForm([
{ id: 1, path: "email", type: "1" },
{ id: 2, path: "email", type: "-1" },
]),
),
).toEqual({ valid: false, error: "field-duplicate", field: "email" });
});
});
@@ -65,6 +65,31 @@ describe("runMongoSidebarMutation", () => {
expect(onSuccess).toHaveBeenCalledWith(result);
});
it("waits for asynchronous success work before clearing loading", async () => {
const loading = ref(false);
let releaseSuccess: () => void = () => {};
const successWork = new Promise<void>((resolve) => {
releaseSuccess = resolve;
});
const onSuccess = vi.fn(async () => successWork);
const pending = runMongoSidebarMutation({
connection: connection(),
database: "app",
reviewText: 'db.getCollection("users").createIndex({"email":1})',
source: "Object tree",
loading,
execute: async () => ({ name: "email_1" }),
onSuccess,
});
await vi.waitFor(() => expect(onSuccess).toHaveBeenCalledOnce());
expect(loading.value).toBe(true);
releaseSuccess();
await pending;
expect(loading.value).toBe(false);
});
it("does not execute or call onSuccess when production confirmation is cancelled", async () => {
const loading = ref(false);
const execute = vi.fn().mockResolvedValue(undefined);
@@ -1,5 +1,34 @@
import type { MongoCollectionKind, TreeNode } from "@/types/database";
export const MONGO_INDEX_KEY_TYPES = ["1", "-1", "text", "hashed", "2dsphere", "2d"] as const;
export type MongoIndexKeyType = (typeof MONGO_INDEX_KEY_TYPES)[number];
export interface MongoCreateIndexField {
id: number;
path: string;
type: MongoIndexKeyType;
}
export interface MongoCreateIndexForm {
name: string;
fields: MongoCreateIndexField[];
unique: boolean;
sparse: boolean;
}
export type MongoCreateIndexRequest =
| {
valid: true;
keysJson: string;
optionsJson?: string;
}
| {
valid: false;
error: "field-required" | "field-duplicate";
field?: string;
};
/**
* MongoDB only supports renameCollection for ordinary, non-system collections.
* Views, time-series collections, and reserved system namespaces must not expose a rename action.
@@ -17,6 +46,11 @@ export function mongoCollectionKindFromNode(node: Pick<TreeNode, "meta">): Mongo
return "collection";
}
export function mongoCollectionTableTypeFromNode(node: Pick<TreeNode, "meta">): "TABLE" | "VIEW" | "TIMESERIES" {
const kind = mongoCollectionKindFromNode(node);
return kind === "view" ? "VIEW" : kind === "timeseries" ? "TIMESERIES" : "TABLE";
}
export function toMongoCollectionKind(kind?: string | null): MongoCollectionKind {
const normalized = (kind || "collection").toLowerCase();
if (normalized === "view") return "view";
@@ -43,3 +77,44 @@ export function mongoDropIndexPreview(database: string, collection: string, inde
export function mongoDropAllIndexesPreview(database: string, collection: string): string {
return `db.getSiblingDB(${JSON.stringify(database)}).getCollection(${JSON.stringify(collection)}).dropIndexes()`;
}
export function mongoDropIndexFailureCount(result: { failures?: readonly unknown[] }): number {
return result.failures?.length ?? 0;
}
/** MongoDB always protects its default _id index, even when metadata is incomplete. */
export function isProtectedMongoIndex(index: { name: string; is_primary?: boolean }): boolean {
return index.name === "_id_" || !!index.is_primary;
}
/** Convert the visual form into the driver's JSON transport format. */
export function buildMongoCreateIndexRequest(form: MongoCreateIndexForm): MongoCreateIndexRequest {
const fields = form.fields.map((field) => ({ ...field, path: field.path.trim() }));
if (fields.length === 0 || fields.some((field) => !field.path)) return { valid: false, error: "field-required" };
const seen = new Set<string>();
for (const field of fields) {
if (seen.has(field.path)) return { valid: false, error: "field-duplicate", field: field.path };
seen.add(field.path);
}
// Build the object text directly so compound indexes retain their visual row order,
// including when a field name looks like an integer.
const keysJson = `{${fields.map((field) => `${JSON.stringify(field.path)}:${JSON.stringify(field.type === "1" ? 1 : field.type === "-1" ? -1 : field.type)}`).join(",")}}`;
const options = {
...(form.name.trim() ? { name: form.name.trim() } : {}),
...(form.unique ? { unique: true } : {}),
...(form.sparse ? { sparse: true } : {}),
};
const optionsJson = Object.keys(options).length ? JSON.stringify(options) : undefined;
return { valid: true, keysJson, optionsJson };
}
/**
* Keep the preview and production confirmation byte-for-byte aligned with
* the JSON passed to the backend. Re-serializing parsed JSON could reorder a
* compound key or round a large numeric value before the user reviews it.
*/
export function mongoCreateIndexPreview(database: string, collection: string, keysJson: string, optionsJson?: string): string {
const args = [keysJson, ...(optionsJson ? [optionsJson] : [])];
return `db.getSiblingDB(${JSON.stringify(database)}).getCollection(${JSON.stringify(collection)}).createIndex(${args.join(", ")})`;
}
@@ -11,7 +11,7 @@ export type RunMongoSidebarMutationOptions<T> = {
/** Runs after production confirmation and after loading is set (e.g. ensureConnected). */
beforeExecute?: () => Promise<void>;
execute: () => Promise<T>;
onSuccess: (result: T) => void;
onSuccess: (result: T) => void | Promise<void>;
onError?: (error: unknown) => void;
};
@@ -41,10 +41,13 @@ export async function runMongoSidebarMutation<T>(options: RunMongoSidebarMutatio
});
// Cancel only: guard returns undefined when the user declines production confirmation.
if (executed === undefined) return;
options.onSuccess(executed.result);
await options.onSuccess(executed.result);
} catch (error: unknown) {
options.onError?.(error);
} finally {
options.loading.value = false;
}
}
// DataGrid and sidebar mutations intentionally share the same production-gated lifecycle.
export const runMongoMutation = runMongoSidebarMutation;
@@ -0,0 +1,14 @@
import { describe, expect, it } from "vitest";
import { getTableMetadataCapabilities } from "@/lib/table/tableMetadataCapabilities";
describe("tableMetadataCapabilities", () => {
it("exposes only collection indexes for MongoDB table information", () => {
expect(getTableMetadataCapabilities("mongodb")).toEqual({
columns: false,
indexes: true,
foreignKeys: false,
triggers: false,
ddl: false,
});
});
});
@@ -17,6 +17,12 @@ const defaultCapabilities: TableMetadataCapabilities = {
};
const capabilityByType: Partial<Record<DatabaseType, Partial<TableMetadataCapabilities>>> = {
mongodb: {
columns: false,
foreignKeys: false,
triggers: false,
ddl: false,
},
clickhouse: {
foreignKeys: false,
triggers: false,