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
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user