From 799bdd1a6278f5927b6832d285c5238f6c27bd9a Mon Sep 17 00:00:00 2001 From: kirito <65323513+kir1ito@users.noreply.github.com> Date: Fri, 14 Aug 2026 12:58:03 +0800 Subject: [PATCH] feat(mongodb): support safe index editing --- .../sidebar/MongoIndexManagerDialog.vue | 262 ++++++++++ .../sidebar/SidebarTreeItemDialogs.vue | 5 + .../sidebar/SidebarTreeRuntimeHost.vue | 49 ++ .../components/sidebar/sidebarAsyncDialogs.ts | 1 + .../sidebar/sidebarTreeDialogState.ts | 41 +- ...abaseSpecificMutationRuntime.mongo.spec.ts | 487 +++++++++++++++++- ...eSidebarDatabaseSpecificMutationRuntime.ts | 342 +++++++++++- apps/desktop/src/i18n/locales/en.ts | 34 ++ apps/desktop/src/i18n/locales/es.ts | 34 ++ apps/desktop/src/i18n/locales/it.ts | 34 ++ apps/desktop/src/i18n/locales/ja.ts | 34 ++ apps/desktop/src/i18n/locales/ko.ts | 34 ++ apps/desktop/src/i18n/locales/pt-BR.ts | 34 ++ apps/desktop/src/i18n/locales/zh-CN.ts | 34 ++ apps/desktop/src/i18n/locales/zh-TW.ts | 34 ++ apps/desktop/src/lib/backend/api.ts | 1 + apps/desktop/src/lib/backend/http.ts | 9 + apps/desktop/src/lib/backend/tauri.ts | 34 ++ .../__tests__/mongoCollectionMutation.spec.ts | 344 ++++++++++++- .../lib/sidebar/mongoCollectionMutation.ts | 320 +++++++++++- crates/dbx-core/src/db/mongo_driver.rs | 331 ++++++++++++ crates/dbx-core/src/mongo_ops.rs | 37 ++ crates/dbx-web/src/main.rs | 1 + crates/dbx-web/src/routes/mongo.rs | 17 + docs/mongo-index-management.md | 199 +++++++ src-tauri/src/commands/mongo_cmd.rs | 11 + src-tauri/src/lib.rs | 1 + 27 files changed, 2754 insertions(+), 10 deletions(-) create mode 100644 apps/desktop/src/components/sidebar/MongoIndexManagerDialog.vue create mode 100644 docs/mongo-index-management.md diff --git a/apps/desktop/src/components/sidebar/MongoIndexManagerDialog.vue b/apps/desktop/src/components/sidebar/MongoIndexManagerDialog.vue new file mode 100644 index 0000000000..de3a148551 --- /dev/null +++ b/apps/desktop/src/components/sidebar/MongoIndexManagerDialog.vue @@ -0,0 +1,262 @@ + + + diff --git a/apps/desktop/src/components/sidebar/SidebarTreeItemDialogs.vue b/apps/desktop/src/components/sidebar/SidebarTreeItemDialogs.vue index 4ebd94f26e..fa1f89ecff 100644 --- a/apps/desktop/src/components/sidebar/SidebarTreeItemDialogs.vue +++ b/apps/desktop/src/components/sidebar/SidebarTreeItemDialogs.vue @@ -7,6 +7,7 @@ import { Input } from "@/components/ui/input"; import { SearchableSelect } from "@/components/ui/searchable-select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; +import { SidebarMongoIndexManagerDialog } from "./sidebarAsyncDialogs"; const props = defineProps<{ controller: Record }>(); const emit = defineEmits<{ closed: [] }>(); @@ -112,6 +113,7 @@ const { addMongoCreateIndexField, removeMongoCreateIndexField, confirmCreateMongoIndex, + showMongoIndexManagerDialog, showRedisDatabaseAliasDialog, redisDatabaseAliasInput, redisDatabaseAliasSaving, @@ -183,6 +185,7 @@ watch( showRenameMongoCollectionDialog, showCloneMongoCollectionDialog, showCreateMongoIndexDialog, + showMongoIndexManagerDialog, showRedisDatabaseAliasDialog, showCreateSchemaDialog, showEditSchemaCommentDialog, @@ -755,4 +758,6 @@ watch( + + diff --git a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue index 3c3138187f..80bd7dabdc 100644 --- a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue +++ b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue @@ -258,6 +258,13 @@ import { mongoCreateIndexFieldOptions, mongoCreateIndexError, mongoCreateIndexLoading, + showMongoIndexManagerDialog, + mongoIndexManagerRows, + mongoIndexManagerLoading, + mongoIndexManagerError, + mongoIndexManagerSelectedName, + mongoIndexManagerMode, + mongoEditIndexOriginalName, showFlushRedisDbConfirm, showCreateSchemaDialog, createSchemaName, @@ -444,6 +451,19 @@ const { addMongoCreateIndexField, removeMongoCreateIndexField, confirmCreateMongoIndex, + canManageMongoIndexes, + prepareMongoIndexManagerDialog, + loadMongoIndexManagerRows, + mongoIndexManagerSelected, + mongoIndexManagerCollectionName, + selectMongoIndexRow, + startCreateMongoIndexDraft, + startEditMongoIndexDraft, + cancelMongoIndexDraft, + dropSelectedMongoIndexRow, + canDropSelectedMongoIndexRow, + canEditSelectedMongoIndexRow, + confirmEditMongoIndex, openCreateNacosNamespaceDialog, confirmCreateNacosNamespace, openEditNacosNamespaceDialog, @@ -1136,6 +1156,12 @@ function openCreateMongoIndexDialog() { prepareCreateMongoIndexDialog(); } +function openMongoIndexManagerDialog() { + claimTreeItemDialogOwnership(); + routeTreeItemDialogController(); + prepareMongoIndexManagerDialog(); +} + function openRedisDatabaseAliasDialog() { claimTreeItemDialogOwnership(); routeTreeItemDialogController(); @@ -4190,6 +4216,24 @@ function databaseSpecificDialogCapabilities() { addMongoCreateIndexField, removeMongoCreateIndexField, confirmCreateMongoIndex, + showMongoIndexManagerDialog, + mongoIndexManagerRows, + mongoIndexManagerLoading, + mongoIndexManagerError, + mongoIndexManagerSelectedName, + mongoIndexManagerMode, + mongoIndexManagerSelected, + mongoIndexManagerCollectionName, + selectMongoIndexRow, + startCreateMongoIndexDraft, + startEditMongoIndexDraft, + cancelMongoIndexDraft, + dropSelectedMongoIndexRow, + canDropSelectedMongoIndexRow, + canEditSelectedMongoIndexRow, + confirmEditMongoIndex, + mongoEditIndexOriginalName, + loadMongoIndexManagerRows, showRedisDatabaseAliasDialog, redisDatabaseAliasInput, redisDatabaseAliasSaving, @@ -4797,6 +4841,11 @@ function buildSpecialSidebarMenu(context: SidebarMenuFactoryContext): boolean { items.push({ label: "", separator: true }); items.push({ label: t("contextMenu.viewData"), action: toggle, icon: TableProperties }); items.push({ label: t("contextMenu.newQuery"), action: newQuery, icon: TerminalSquare }); + // Creating and dropping indexes stay on the Indexes group node; the collection + // only opens the manager panel, which offers creation from inside itself. + if (canManageMongoIndexes.value) { + items.push({ label: t("contextMenu.manageMongoIndexes"), action: openMongoIndexManagerDialog, icon: PencilRuler }); + } if (canRenameMongoCollection.value) { items.push({ label: t("contextMenu.renameObject"), diff --git a/apps/desktop/src/components/sidebar/sidebarAsyncDialogs.ts b/apps/desktop/src/components/sidebar/sidebarAsyncDialogs.ts index a4f0f7f2de..824d379fac 100644 --- a/apps/desktop/src/components/sidebar/sidebarAsyncDialogs.ts +++ b/apps/desktop/src/components/sidebar/sidebarAsyncDialogs.ts @@ -23,3 +23,4 @@ export const SidebarVisibleSchemasDialog = lazySidebarDialog(() => import("@/com export const SidebarDdlViewDialog = lazySidebarDialog(() => import("@/components/objects/DdlViewDialog.vue")); export const SidebarObjectSourceDialog = lazySidebarDialog(() => import("@/components/objects/ObjectSourceDialog.vue")); export const SidebarProcedureExecutionDialog = lazySidebarDialog(() => import("@/components/objects/ProcedureExecutionDialog.vue")); +export const SidebarMongoIndexManagerDialog = lazySidebarDialog(() => import("@/components/sidebar/MongoIndexManagerDialog.vue")); diff --git a/apps/desktop/src/components/sidebar/sidebarTreeDialogState.ts b/apps/desktop/src/components/sidebar/sidebarTreeDialogState.ts index 51d15abad4..6711592dab 100644 --- a/apps/desktop/src/components/sidebar/sidebarTreeDialogState.ts +++ b/apps/desktop/src/components/sidebar/sidebarTreeDialogState.ts @@ -4,7 +4,7 @@ import type { PasteTableMode } from "@/lib/table/tableClipboard"; import { fallbackCreateDatabaseCharsetMetadata } from "@/lib/database/createDatabaseCharsetOptions"; import type { DatabaseUserIdentity } from "@/lib/database/databaseUserAdmin"; import type { AuthorizationPlan, AuthorizationStepResult } from "@/lib/database/databaseAuthorizationPlan"; -import type { MongoCreateIndexForm } from "@/lib/sidebar/mongoCollectionMutation"; +import type { MongoCreateIndexForm, MongoIndexRow } from "@/lib/sidebar/mongoCollectionMutation"; export type DuplicateStructureSource = TreeNode & { connectionId: string; database: string }; type ConnectionDeleteTarget = TreeNode & { connectionId: string }; @@ -106,17 +106,50 @@ export const dropMongoIndexLoading = ref(false); export const showDropAllMongoIndexesConfirm = ref(false); export const dropAllMongoIndexesLoading = ref(false); export const showCreateMongoIndexDialog = ref(false); -export const mongoCreateIndexForm = ref({ name: "", fields: [{ id: 1, path: "", type: "1" }], unique: false, sparse: false }); + +function emptyMongoCreateIndexForm(): MongoCreateIndexForm { + return { + name: "", + fields: [{ id: 1, path: "", type: "1" }], + unique: false, + sparse: false, + expireAfterSeconds: "", + partialFilterExpression: "", + background: false, + bucketSize: "", + hidden: false, + }; +} + +export const mongoCreateIndexForm = ref(emptyMongoCreateIndexForm()); export const mongoCreateIndexFieldOptions = ref([]); export const mongoCreateIndexError = ref(""); export const mongoCreateIndexLoading = ref(false); export function resetMongoCreateIndexForm() { - mongoCreateIndexForm.value = { name: "", fields: [{ id: 1, path: "", type: "1" }], unique: false, sparse: false }; + mongoCreateIndexForm.value = emptyMongoCreateIndexForm(); mongoCreateIndexFieldOptions.value = []; mongoCreateIndexError.value = ""; mongoCreateIndexLoading.value = false; } + +export const showMongoIndexManagerDialog = ref(false); +export const mongoIndexManagerRows = ref([]); +export const mongoIndexManagerLoading = ref(false); +export const mongoIndexManagerError = ref(""); +export const mongoIndexManagerSelectedName = ref(""); +export const mongoIndexManagerMode = ref<"view" | "create" | "edit">("view"); +/** Name of the index being edited, so the confirm step knows which one to drop. */ +export const mongoEditIndexOriginalName = ref(""); + +export function resetMongoIndexManager() { + mongoIndexManagerRows.value = []; + mongoIndexManagerLoading.value = false; + mongoIndexManagerError.value = ""; + mongoIndexManagerSelectedName.value = ""; + mongoIndexManagerMode.value = "view"; + mongoEditIndexOriginalName.value = ""; +} export const showFlushRedisDbConfirm = ref(false); export const showRedisDatabaseAliasDialog = ref(false); export const redisDatabaseAliasInput = ref(""); @@ -165,6 +198,7 @@ const openFlags = [ showDropMongoIndexConfirm, showDropAllMongoIndexesConfirm, showCreateMongoIndexDialog, + showMongoIndexManagerDialog, showFlushRedisDbConfirm, showRedisDatabaseAliasDialog, showCreateSchemaDialog, @@ -190,6 +224,7 @@ export function resetSidebarTreeDialogState() { cloneMongoCollectionError.value = ""; cloneMongoCollectionLoading.value = false; resetMongoCreateIndexForm(); + resetMongoIndexManager(); sidebarTreeDialogOwner.value = null; sidebarDangerTarget.value = null; sidebarFormTarget.value = null; diff --git a/apps/desktop/src/composables/__tests__/useSidebarDatabaseSpecificMutationRuntime.mongo.spec.ts b/apps/desktop/src/composables/__tests__/useSidebarDatabaseSpecificMutationRuntime.mongo.spec.ts index 29183eb4b8..2b0e992084 100644 --- a/apps/desktop/src/composables/__tests__/useSidebarDatabaseSpecificMutationRuntime.mongo.spec.ts +++ b/apps/desktop/src/composables/__tests__/useSidebarDatabaseSpecificMutationRuntime.mongo.spec.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { createPinia, setActivePinia } from "pinia"; -import { shallowRef } from "vue"; +import { nextTick, shallowRef } from "vue"; import type { TreeNode } from "@/types/database"; import { cloneMongoCollectionError, @@ -8,19 +8,28 @@ import { cloneMongoCollectionName, mongoCreateIndexError, mongoCreateIndexForm, + mongoEditIndexOriginalName, + mongoIndexManagerError, + mongoIndexManagerLoading, + mongoIndexManagerMode, + mongoIndexManagerRows, + mongoIndexManagerSelectedName, resetMongoCreateIndexForm, + resetMongoIndexManager, sidebarDangerTarget, sidebarFormTarget, showCloneMongoCollectionDialog, showCreateMongoIndexDialog, showDropAllMongoIndexesConfirm, showDropMongoCollectionConfirm, + showMongoIndexManagerDialog, } from "@/components/sidebar/sidebarTreeDialogState"; const mocks = vi.hoisted(() => ({ toast: vi.fn(), ensureConnected: vi.fn().mockResolvedValue(undefined), loadIndexes: vi.fn().mockResolvedValue(undefined), + mongoListIndexSpecs: vi.fn().mockResolvedValue([]), listMongoCompletionFields: vi.fn().mockResolvedValue([{ name: "email", type: "string" }]), loadMongoCollections: vi.fn().mockResolvedValue(undefined), loadMongoDatabases: vi.fn().mockResolvedValue(undefined), @@ -48,6 +57,7 @@ vi.mock("@/stores/connectionStore", () => ({ })); vi.mock("@/lib/backend/api", () => ({ + mongoListIndexSpecs: (...args: unknown[]) => mocks.mongoListIndexSpecs(...args), mongoCreateIndex: (...args: unknown[]) => mocks.mongoCreateIndex(...args), mongoCloneCollection: (...args: unknown[]) => mocks.mongoCloneCollection(...args), mongoDropCollection: (...args: unknown[]) => mocks.mongoDropCollection(...args), @@ -129,6 +139,13 @@ function mongoIndexNode(name: string, kind: "collection" | "view" | "timeseries" }; } +/** Let the panel's fire-and-forget index load settle before asserting. */ +async function flush(): Promise { + await Promise.resolve(); + await Promise.resolve(); + await nextTick(); +} + function runtime(activeNode: TreeNode) { const indexesGroup = activeNode.type === "group-indexes" ? activeNode : mongoIndexesGroupNode(); return useSidebarDatabaseSpecificMutationRuntime({ @@ -153,6 +170,23 @@ describe("MongoDB sidebar mutation runtime", () => { mocks.getConfig.mockReturnValue(mongoConfig()); mocks.ensureConnected.mockResolvedValue(undefined); mocks.loadIndexes.mockResolvedValue(undefined); + mocks.mongoListIndexSpecs.mockResolvedValue([ + { name: "_id_", keys: [{ field: "_id", direction: "1" }], is_unique: true, is_primary: true, is_sparse: false, expire_after_seconds: null, partial_filter_expression: null, background: false, bucket_size: null, hidden: false, properties_complete: true, extra_options: null }, + { + name: "email_1", + keys: [{ field: "email", direction: "1" }], + is_unique: false, + is_primary: false, + is_sparse: true, + expire_after_seconds: 3600, + partial_filter_expression: '{"archived":false}', + background: false, + bucket_size: null, + hidden: false, + properties_complete: true, + extra_options: null, + }, + ]); mocks.listMongoCompletionFields.mockResolvedValue([{ name: "email", type: "string" }]); mocks.loadMongoCollections.mockResolvedValue(undefined); mocks.loadMongoDatabases.mockResolvedValue(undefined); @@ -167,7 +201,9 @@ describe("MongoDB sidebar mutation runtime", () => { showCloneMongoCollectionDialog.value = false; showDropAllMongoIndexesConfirm.value = false; showDropMongoCollectionConfirm.value = false; + showMongoIndexManagerDialog.value = false; resetMongoCreateIndexForm(); + resetMongoIndexManager(); cloneMongoCollectionName.value = ""; cloneMongoCollectionError.value = ""; cloneMongoCollectionLoading.value = false; @@ -306,6 +342,10 @@ describe("MongoDB sidebar mutation runtime", () => { fields: [{ id: 7, path: "location", type: "2dsphere" }], unique: true, sparse: true, + expireAfterSeconds: "600", + partialFilterExpression: '{"active":true}', + background: true, + bucketSize: "32", }; mongoCreateIndexError.value = "previous failure"; @@ -316,6 +356,11 @@ describe("MongoDB sidebar mutation runtime", () => { fields: [{ id: 1, path: "", type: "1" }], unique: false, sparse: false, + expireAfterSeconds: "", + partialFilterExpression: "", + background: false, + bucketSize: "", + hidden: false, }); expect(mongoCreateIndexError.value).toBe(""); expect(showCreateMongoIndexDialog.value).toBe(true); @@ -333,6 +378,446 @@ describe("MongoDB sidebar mutation runtime", () => { expect(mocks.mongoCreateIndex).not.toHaveBeenCalled(); }); + it("opens the index manager panel from a collection node and lists its indexes", async () => { + const node = mongoCollectionNode(); + const feature = runtime(node); + sidebarFormTarget.value = node; + + expect(feature.canManageMongoIndexes.value).toBe(true); + feature.prepareMongoIndexManagerDialog(); + await flush(); + + expect(showMongoIndexManagerDialog.value).toBe(true); + expect(mocks.mongoListIndexSpecs).toHaveBeenCalledWith("conn-1", "app", "users"); + expect(mongoIndexManagerRows.value).toEqual([ + { + name: "_id_", + keys: "_id ASC", + isUnique: true, + isProtected: true, + isSparse: false, + expireAfterSeconds: undefined, + partialFilterExpression: undefined, + background: false, + bucketSize: undefined, + hidden: false, + propertiesComplete: true, + extraOptions: undefined, + }, + { + name: "email_1", + keys: "email ASC", + isUnique: false, + isProtected: false, + isSparse: true, + expireAfterSeconds: 3600, + partialFilterExpression: '{"archived":false}', + background: false, + bucketSize: undefined, + hidden: false, + propertiesComplete: true, + extraOptions: undefined, + }, + ]); + // The first row is auto-selected so the property pane is never blank. + expect(mongoIndexManagerSelectedName.value).toBe("_id_"); + expect(feature.canDropSelectedMongoIndexRow.value).toBe(false); + }); + + it("creates an index from the panel opened on a collection node and returns to view mode", async () => { + const node = mongoCollectionNode(); + const feature = runtime(node); + sidebarFormTarget.value = node; + + feature.prepareMongoIndexManagerDialog(); + await flush(); + + feature.startCreateMongoIndexDraft(); + expect(mongoIndexManagerMode.value).toBe("create"); + mongoCreateIndexForm.value = { ...mongoCreateIndexForm.value, fields: [{ id: 1, path: "email", type: "1" }], expireAfterSeconds: "3600" }; + await feature.confirmCreateMongoIndex(); + await flush(); + + expect(mocks.mongoCreateIndex).toHaveBeenCalledWith("conn-1", "app", "users", '{"email":1}', '{"expireAfterSeconds":3600}'); + // The panel stays open, drops back to read-only, and selects the new index. + expect(showMongoIndexManagerDialog.value).toBe(true); + expect(mongoIndexManagerMode.value).toBe("view"); + expect(mongoIndexManagerSelectedName.value).toBe("email_1"); + }); + + it("drops the selected index from the panel while protecting the default index", async () => { + const node = mongoCollectionNode(); + const feature = runtime(node); + sidebarFormTarget.value = node; + + feature.prepareMongoIndexManagerDialog(); + await flush(); + + feature.selectMongoIndexRow("email_1"); + expect(feature.canDropSelectedMongoIndexRow.value).toBe(true); + await feature.dropSelectedMongoIndexRow(); + + expect(mocks.mongoDropIndexes).toHaveBeenCalledWith("conn-1", "app", "users", '"email_1"', true); + + feature.selectMongoIndexRow("_id_"); + mocks.mongoDropIndexes.mockClear(); + await feature.dropSelectedMongoIndexRow(); + + expect(mocks.mongoDropIndexes).not.toHaveBeenCalled(); + }); + + it("edits the selected index by dropping then creating and returns to view mode", async () => { + const node = mongoCollectionNode(); + const feature = runtime(node); + sidebarFormTarget.value = node; + + feature.prepareMongoIndexManagerDialog(); + await flush(); + + feature.selectMongoIndexRow("email_1"); + expect(feature.canEditSelectedMongoIndexRow.value).toBe(true); + + feature.startEditMongoIndexDraft(); + expect(mongoIndexManagerMode.value).toBe("edit"); + expect(mongoEditIndexOriginalName.value).toBe("email_1"); + // The form is prefilled from the selected row. + expect(mongoCreateIndexForm.value.name).toBe("email_1"); + expect(mongoCreateIndexForm.value.fields).toEqual([{ id: 1, path: "email", type: "1" }]); + expect(mongoCreateIndexForm.value.sparse).toBe(true); + expect(mongoCreateIndexForm.value.expireAfterSeconds).toBe("3600"); + + // Rename: create new first, then drop old. + mongoCreateIndexForm.value.name = "email_renamed"; + await feature.confirmEditMongoIndex(); + await flush(); + + // Re-read happens before any mutation; then create(new) before drop(old). + expect(mocks.mongoListIndexSpecs).toHaveBeenCalledWith("conn-1", "app", "users"); + expect(mocks.mongoCreateIndex).toHaveBeenCalledWith("conn-1", "app", "users", '{"email":1}', '{"name":"email_renamed","sparse":true,"expireAfterSeconds":3600,"partialFilterExpression":{"archived":false}}'); + expect(mocks.mongoDropIndexes).toHaveBeenCalledWith("conn-1", "app", "users", '"email_1"', true); + expect(mongoIndexManagerMode.value).toBe("view"); + expect(mongoEditIndexOriginalName.value).toBe(""); + expect(mongoIndexManagerSelectedName.value).toBe("email_1"); + }); + + it("does not edit the protected _id index", async () => { + const node = mongoCollectionNode(); + const feature = runtime(node); + sidebarFormTarget.value = node; + + feature.prepareMongoIndexManagerDialog(); + await flush(); + + feature.selectMongoIndexRow("_id_"); + expect(feature.canEditSelectedMongoIndexRow.value).toBe(false); + + feature.startEditMongoIndexDraft(); + expect(mongoIndexManagerMode.value).toBe("view"); + expect(mocks.mongoDropIndexes).not.toHaveBeenCalled(); + expect(mocks.mongoCreateIndex).not.toHaveBeenCalled(); + }); + + it("does not edit or rename an index without a complete server specification", async () => { + mocks.mongoListIndexSpecs.mockResolvedValueOnce([ + { name: "_id_", keys: [{ field: "_id", direction: "1" }], is_unique: true, is_primary: true, is_sparse: false, expire_after_seconds: null, partial_filter_expression: null, background: false, bucket_size: null, hidden: false, properties_complete: true, extra_options: null }, + { + name: "email_1", + keys: [{ field: "email", direction: "1" }], + is_unique: false, + is_primary: false, + is_sparse: false, + expire_after_seconds: null, + partial_filter_expression: null, + background: false, + bucket_size: null, + hidden: false, + properties_complete: false, + extra_options: null, + }, + ]); + const node = mongoCollectionNode(); + const feature = runtime(node); + sidebarFormTarget.value = node; + + feature.prepareMongoIndexManagerDialog(); + await flush(); + feature.selectMongoIndexRow("email_1"); + + expect(feature.canEditSelectedMongoIndexRow.value).toBe(false); + feature.startEditMongoIndexDraft(); + expect(mongoIndexManagerMode.value).toBe("view"); + expect(mocks.mongoCreateIndex).not.toHaveBeenCalled(); + expect(mocks.mongoDropIndexes).not.toHaveBeenCalled(); + }); + + it("creates the new index first when renaming and does not drop if create fails", async () => { + const node = mongoCollectionNode(); + const feature = runtime(node); + sidebarFormTarget.value = node; + + feature.prepareMongoIndexManagerDialog(); + await flush(); + + feature.selectMongoIndexRow("email_1"); + feature.startEditMongoIndexDraft(); + mongoCreateIndexForm.value.name = "email_v2"; + + mocks.mongoCreateIndex.mockRejectedValueOnce(new Error("duplicate key")); + await feature.confirmEditMongoIndex(); + await flush(); + + // Create was attempted; drop was never called because create failed. + expect(mocks.mongoCreateIndex).toHaveBeenCalled(); + expect(mocks.mongoDropIndexes).not.toHaveBeenCalled(); + // The error surfaces in the panel. + expect(mongoCreateIndexError.value).toContain("duplicate key"); + }); + + it("restores the complete original specification when a same-name rebuild fails", async () => { + const node = mongoCollectionNode(); + const feature = runtime(node); + sidebarFormTarget.value = node; + + feature.prepareMongoIndexManagerDialog(); + await flush(); + + feature.selectMongoIndexRow("email_1"); + feature.startEditMongoIndexDraft(); + mongoCreateIndexForm.value.unique = true; + + mocks.mongoCreateIndex.mockRejectedValueOnce(new Error("index build failed")).mockResolvedValueOnce({ name: "email_1" }); + await feature.confirmEditMongoIndex(); + await flush(); + + expect(mocks.mongoDropIndexes).toHaveBeenCalledWith("conn-1", "app", "users", '"email_1"', true); + expect(mocks.mongoCreateIndex).toHaveBeenNthCalledWith(1, "conn-1", "app", "users", '{"email":1}', '{"name":"email_1","unique":true,"sparse":true,"expireAfterSeconds":3600,"partialFilterExpression":{"archived":false}}'); + expect(mocks.mongoCreateIndex).toHaveBeenNthCalledWith(2, "conn-1", "app", "users", '{"email":1}', '{"name":"email_1","sparse":true,"expireAfterSeconds":3600,"partialFilterExpression":{"archived":false}}'); + expect(mongoCreateIndexError.value).toContain("email_1"); + expect(mongoCreateIndexError.value).toContain("index build failed"); + expect(mongoCreateIndexError.value).toContain("mongoEditIndexCreateFailedRolledBack"); + }); + + it("reports both replacement and rollback errors when restoring a same-name index fails", async () => { + const node = mongoCollectionNode(); + const feature = runtime(node); + sidebarFormTarget.value = node; + + feature.prepareMongoIndexManagerDialog(); + await flush(); + feature.selectMongoIndexRow("email_1"); + feature.startEditMongoIndexDraft(); + mongoCreateIndexForm.value.unique = true; + + mocks.mongoCreateIndex.mockRejectedValueOnce(new Error("replacement failed")).mockRejectedValueOnce(new Error("rollback failed")); + await feature.confirmEditMongoIndex(); + await flush(); + + expect(mocks.mongoCreateIndex).toHaveBeenCalledTimes(2); + expect(mongoCreateIndexError.value).toContain("replacement failed"); + expect(mongoCreateIndexError.value).toContain("rollback failed"); + }); + + it("aborts the edit when the index no longer exists on the server", async () => { + const node = mongoCollectionNode(); + const feature = runtime(node); + sidebarFormTarget.value = node; + + feature.prepareMongoIndexManagerDialog(); + await flush(); + + feature.selectMongoIndexRow("email_1"); + feature.startEditMongoIndexDraft(); + + // Simulate the index being deleted by another session before confirm. + mocks.mongoListIndexSpecs.mockResolvedValueOnce([ + { name: "_id_", keys: [{ field: "_id", direction: "1" }], is_unique: true, is_primary: true, is_sparse: false, expire_after_seconds: null, partial_filter_expression: null, background: false, bucket_size: null, hidden: false, properties_complete: true, extra_options: null }, + ]); + await feature.confirmEditMongoIndex(); + await flush(); + + expect(mocks.mongoDropIndexes).not.toHaveBeenCalled(); + expect(mocks.mongoCreateIndex).not.toHaveBeenCalled(); + expect(mongoCreateIndexError.value).toContain("email_1"); + }); + + it("aborts the edit when the index keys changed since the dialog was opened", async () => { + const node = mongoCollectionNode(); + const feature = runtime(node); + sidebarFormTarget.value = node; + + feature.prepareMongoIndexManagerDialog(); + await flush(); + + feature.selectMongoIndexRow("email_1"); + feature.startEditMongoIndexDraft(); + + // Simulate the index being modified by another session (direction changed). + mocks.mongoListIndexSpecs.mockResolvedValueOnce([ + { name: "_id_", keys: [{ field: "_id", direction: "1" }], is_unique: true, is_primary: true, is_sparse: false, expire_after_seconds: null, partial_filter_expression: null, background: false, bucket_size: null, hidden: false, properties_complete: true, extra_options: null }, + { name: "email_1", keys: [{ field: "email", direction: "-1" }], is_unique: false, is_primary: false, is_sparse: false, expire_after_seconds: null, partial_filter_expression: null, background: false, bucket_size: null, hidden: false, properties_complete: true, extra_options: null }, + ]); + await feature.confirmEditMongoIndex(); + await flush(); + + expect(mocks.mongoDropIndexes).not.toHaveBeenCalled(); + expect(mocks.mongoCreateIndex).not.toHaveBeenCalled(); + expect(mongoCreateIndexError.value).toContain("email_1"); + }); + + it("aborts the edit when the keys match but the complete server options changed", async () => { + const node = mongoCollectionNode(); + const feature = runtime(node); + sidebarFormTarget.value = node; + + feature.prepareMongoIndexManagerDialog(); + await flush(); + feature.selectMongoIndexRow("email_1"); + feature.startEditMongoIndexDraft(); + + mocks.mongoListIndexSpecs.mockResolvedValueOnce([ + { name: "_id_", keys: [{ field: "_id", direction: "1" }], is_unique: true, is_primary: true, is_sparse: false, expire_after_seconds: null, partial_filter_expression: null, background: false, bucket_size: null, hidden: false, properties_complete: true, extra_options: null }, + { + name: "email_1", + keys: [{ field: "email", direction: "1" }], + is_unique: true, + is_primary: false, + is_sparse: true, + expire_after_seconds: 3600, + partial_filter_expression: '{"archived":false}', + background: false, + bucket_size: null, + hidden: true, + properties_complete: true, + extra_options: '{"collation":{"locale":"fr"}}', + }, + ]); + await feature.confirmEditMongoIndex(); + await flush(); + + expect(mocks.mongoDropIndexes).not.toHaveBeenCalled(); + expect(mocks.mongoCreateIndex).not.toHaveBeenCalled(); + expect(mongoCreateIndexError.value).toContain("mongoEditIndexStale"); + }); + + it("does not report a rename as successful when drop returns structured failures", async () => { + const node = mongoCollectionNode(); + const feature = runtime(node); + sidebarFormTarget.value = node; + + feature.prepareMongoIndexManagerDialog(); + await flush(); + feature.selectMongoIndexRow("email_1"); + feature.startEditMongoIndexDraft(); + mongoCreateIndexForm.value.name = "email_v2"; + mocks.mongoCreateIndex.mockResolvedValueOnce({ name: "email_v2" }); + mocks.mongoDropIndexes.mockResolvedValueOnce({ + dropped_names: [], + affected_rows: 0, + failures: [{ name: "email_1", message: "not authorized" }], + }); + + await feature.confirmEditMongoIndex(); + await flush(); + + expect(mocks.mongoCreateIndex).toHaveBeenCalledOnce(); + expect(mocks.mongoDropIndexes).toHaveBeenCalledOnce(); + expect(mongoIndexManagerMode.value).toBe("edit"); + expect(mongoCreateIndexError.value).toContain("not authorized"); + expect(mongoCreateIndexError.value).toContain("mongoEditIndexDropFailedAfterCreate"); + expect(mocks.toast).not.toHaveBeenCalledWith(expect.stringContaining("mongoEditIndexSuccess"), 3000); + }); + + it("does not create or report rollback when a same-name drop returns structured failures", async () => { + const node = mongoCollectionNode(); + const feature = runtime(node); + sidebarFormTarget.value = node; + + feature.prepareMongoIndexManagerDialog(); + await flush(); + feature.selectMongoIndexRow("email_1"); + feature.startEditMongoIndexDraft(); + mongoCreateIndexForm.value.unique = true; + mocks.mongoDropIndexes.mockResolvedValueOnce({ + dropped_names: [], + affected_rows: 0, + failures: [{ name: "email_1", message: "index is busy" }], + }); + + await feature.confirmEditMongoIndex(); + await flush(); + + expect(mocks.mongoCreateIndex).not.toHaveBeenCalled(); + expect(mongoIndexManagerMode.value).toBe("edit"); + expect(mongoCreateIndexError.value).toContain("index is busy"); + expect(mongoCreateIndexError.value).toContain("mongoEditIndexDropFailed"); + expect(mongoCreateIndexError.value).not.toContain("mongoEditIndexCreateFailedRolledBack"); + }); + + it("preserves extra options (collation, wildcardProjection) through the edit", async () => { + const node = mongoCollectionNode(); + const feature = runtime(node); + sidebarFormTarget.value = node; + + // Seed the list with an index that has extraOptions. + mocks.mongoListIndexSpecs.mockReturnValue([ + { name: "_id_", keys: [{ field: "_id", direction: "1" }], is_unique: true, is_primary: true, is_sparse: false, expire_after_seconds: null, partial_filter_expression: null, background: false, bucket_size: null, hidden: false, properties_complete: true, extra_options: null }, + { + name: "wild_1", + keys: [{ field: "x", direction: "1" }], + is_unique: false, + is_primary: false, + is_sparse: false, + expire_after_seconds: null, + partial_filter_expression: null, + background: false, + bucket_size: null, + hidden: false, + properties_complete: true, + extra_options: '{"wildcardProjection":{"x":1}}', + }, + ]); + + feature.prepareMongoIndexManagerDialog(); + await flush(); + + feature.selectMongoIndexRow("wild_1"); + feature.startEditMongoIndexDraft(); + mongoCreateIndexForm.value.name = "wild_v2"; + await feature.confirmEditMongoIndex(); + await flush(); + + expect(mocks.mongoCreateIndex).toHaveBeenCalled(); + const createArgs = mocks.mongoCreateIndex.mock.calls[0]; + const optionsJson = createArgs[4]; + expect(optionsJson).toContain("wildcardProjection"); + }); + + it("surfaces an index-list failure in the panel instead of showing a stale list", async () => { + const node = mongoCollectionNode(); + const feature = runtime(node); + sidebarFormTarget.value = node; + mocks.mongoListIndexSpecs.mockRejectedValueOnce(new Error("listIndexes failed")); + + feature.prepareMongoIndexManagerDialog(); + await flush(); + + expect(mongoIndexManagerRows.value).toEqual([]); + expect(mongoIndexManagerSelectedName.value).toBe(""); + expect(mongoIndexManagerError.value).toContain("listIndexes failed"); + expect(mongoIndexManagerLoading.value).toBe(false); + }); + + it("does not open the index manager panel for read-only MongoDB connections", () => { + mocks.getConfig.mockReturnValue({ ...mongoConfig(), read_only: true }); + const node = mongoCollectionNode(); + const feature = runtime(node); + + feature.prepareMongoIndexManagerDialog(); + + expect(showMongoIndexManagerDialog.value).toBe(false); + expect(mocks.mongoListIndexSpecs).not.toHaveBeenCalled(); + }); + it("does not clear indexes through a collection node", async () => { const node = mongoCollectionNode(); const feature = runtime(node); diff --git a/apps/desktop/src/composables/useSidebarDatabaseSpecificMutationRuntime.ts b/apps/desktop/src/composables/useSidebarDatabaseSpecificMutationRuntime.ts index 91d71173b4..d207d288e9 100644 --- a/apps/desktop/src/composables/useSidebarDatabaseSpecificMutationRuntime.ts +++ b/apps/desktop/src/composables/useSidebarDatabaseSpecificMutationRuntime.ts @@ -13,8 +13,11 @@ import { isCloneableMongoCollection, isProtectedMongoIndex, isRenamableMongoCollection, + mergeExtraOptionsIntoRequest, mongoCollectionKindFromNode, mongoCloneCollectionPreview, + mongoCreateIndexRequestFromSpec, + mongoCreateIndexFormFromRow, mongoCreateIndexPreview, mongoDropAllIndexesPreview, mongoDropCollectionPreview, @@ -22,6 +25,12 @@ import { mongoDropDatabasePreview, mongoDropIndexPreview, mongoRenameCollectionPreview, + mongoReplaceIndexPreview, + preflightEditIndexSpec, + snapshotMongoIndexSpec, + toMongoIndexRow, + type MongoCreateIndexRequest, + type MongoIndexSpecSnapshot, } from "@/lib/sidebar/mongoCollectionMutation"; import { supportsMongoAllDriverMutations, supportsMongoIndexMutations, supportsNativeMongoDriverMutations } from "@/lib/mongo/mongoCapabilities"; import { runMongoSidebarMutation } from "@/lib/sidebar/runMongoSidebarMutation"; @@ -66,6 +75,14 @@ import { mongoCreateIndexError, mongoCreateIndexLoading, resetMongoCreateIndexForm, + showMongoIndexManagerDialog, + mongoIndexManagerRows, + mongoIndexManagerLoading, + mongoIndexManagerError, + mongoIndexManagerSelectedName, + mongoIndexManagerMode, + mongoEditIndexOriginalName, + resetMongoIndexManager, } from "@/components/sidebar/sidebarTreeDialogState"; interface SidebarDatabaseSpecificMutationRuntimeOptions { @@ -84,6 +101,8 @@ export function useSidebarDatabaseSpecificMutationRuntime(options: SidebarDataba const { t } = useI18n(); const { toast } = useToast(); const { activeNode, connectionStore } = options; + let mongoIndexSpecsByName = new Map(); + let mongoEditIndexOriginalSpec: MongoIndexSpecSnapshot | undefined; function usesAnyMongoDriver(node: Pick): boolean { return !!node.connectionId && supportsMongoAllDriverMutations(connectionStore.getConfig(node.connectionId)); @@ -240,11 +259,25 @@ export function useSidebarDatabaseSpecificMutationRuntime(options: SidebarDataba return node.type === "group-indexes" ? node.tableName || "" : ""; } + /** + * The manager panel is also reachable from the collection node, while direct + * create / drop-all actions stay scoped to the Indexes group. + */ + function mongoIndexPanelCollectionName(node: TreeNode): string { + if (node.type === "mongo-collection") return node.label; + return mongoIndexCollectionName(node); + } + + function canManageMongoIndexPanelNode(node: TreeNode): boolean { + return !!mongoIndexPanelCollectionName(node) && !!node.database && canMutateMongoIndexes(node); + } + function canManageMongoIndexesNode(node: TreeNode): boolean { return !!mongoIndexCollectionName(node) && !!node.database && canMutateMongoIndexes(node); } const canDropAllMongoIndexes = computed(() => canManageMongoIndexesNode(activeNode.value)); + const canManageMongoIndexes = computed(() => canManageMongoIndexPanelNode(activeNode.value)); function mongoIndexDropPreview(node: Pick, indexName: string): string { return mongoDropIndexPreview(node.database || "", node.tableName || "", indexName); @@ -259,7 +292,8 @@ export function useSidebarDatabaseSpecificMutationRuntime(options: SidebarDataba } const canCreateMongoIndex = computed(() => canCreateMongoIndexNode(activeNode.value)); - const mongoCreateIndexCanSubmit = computed(() => mongoCreateIndexForm.value.fields.length > 0 && mongoCreateIndexForm.value.fields.every((field) => !!field.path.trim())); + /** Mirror the full request validation so the button never invites a known error. */ + const mongoCreateIndexCanSubmit = computed(() => buildMongoCreateIndexRequest(mongoCreateIndexForm.value).valid); const mongoCreateIndexCanAddField = computed(() => mongoCreateIndexForm.value.fields.every((field) => !!field.path.trim())); watch( @@ -286,6 +320,155 @@ export function useSidebarDatabaseSpecificMutationRuntime(options: SidebarDataba }); } + async function loadMongoIndexManagerRows() { + const node = sidebarFormTarget.value ?? activeNode.value; + const connectionId = node.connectionId; + const database = node.database; + const collection = mongoIndexPanelCollectionName(node); + if (!connectionId || !database || !collection) return; + + mongoIndexManagerLoading.value = true; + mongoIndexManagerError.value = ""; + try { + await connectionStore.ensureConnected(connectionId); + // MongoDB-specific read: the shared IndexInfo shape cannot carry sparse, + // TTL, background or bucketSize. The backend degrades to the generic + // listing for Legacy Agent connections and flags it there. + const specs = await api.mongoListIndexSpecs(connectionId, database, collection); + mongoIndexSpecsByName = new Map(specs.map((spec) => [spec.name, snapshotMongoIndexSpec(spec)] as const)); + mongoIndexManagerRows.value = specs.map(toMongoIndexRow); + const selected = mongoIndexManagerSelectedName.value; + if (!selected || !mongoIndexManagerRows.value.some((row) => row.name === selected)) { + mongoIndexManagerSelectedName.value = mongoIndexManagerRows.value[0]?.name ?? ""; + } + } catch (error) { + mongoIndexSpecsByName.clear(); + mongoIndexManagerRows.value = []; + mongoIndexManagerSelectedName.value = ""; + mongoIndexManagerError.value = translateBackendError(t, errorMessage(error)); + } finally { + mongoIndexManagerLoading.value = false; + } + } + + function prepareMongoIndexManagerDialog() { + const node = activeNode.value; + if (!canManageMongoIndexPanelNode(node) || !node.connectionId || !node.database) return; + resetMongoIndexManager(); + resetMongoCreateIndexForm(); + mongoIndexSpecsByName.clear(); + mongoEditIndexOriginalSpec = undefined; + showMongoIndexManagerDialog.value = true; + void loadMongoIndexManagerRows(); + } + + const mongoIndexManagerSelected = computed(() => mongoIndexManagerRows.value.find((row) => row.name === mongoIndexManagerSelectedName.value)); + + /** Resolve against the dialog's own target so the header survives tree selection changes. */ + const mongoIndexManagerCollectionName = computed(() => mongoIndexPanelCollectionName(sidebarFormTarget.value ?? activeNode.value)); + + function selectMongoIndexRow(name: string) { + if (mongoIndexManagerMode.value === "create" || mongoIndexManagerMode.value === "edit") return; + mongoIndexManagerSelectedName.value = name; + } + + /** Switch the property pane into an editable draft for a brand new index. */ + function startCreateMongoIndexDraft() { + const node = sidebarFormTarget.value ?? activeNode.value; + resetMongoCreateIndexForm(); + mongoIndexManagerMode.value = "create"; + mongoIndexManagerError.value = ""; + if (!node.connectionId || !node.database) return; + const collection = mongoIndexPanelCollectionName(node); + if (!collection) return; + void connectionStore + .listMongoCompletionFields(node.connectionId, node.database, collection) + .then((fields) => { + if (showMongoIndexManagerDialog.value) mongoCreateIndexFieldOptions.value = fields.map((field) => field.name); + }) + .catch(() => { + // MongoDB is schemaless; a field that was never sampled is still valid. + }); + } + + function cancelMongoIndexDraft() { + mongoIndexManagerMode.value = "view"; + mongoEditIndexOriginalName.value = ""; + mongoEditIndexOriginalSpec = undefined; + resetMongoCreateIndexForm(); + } + + /** The default _id index is never droppable, so the panel must not offer it. */ + const canDropSelectedMongoIndexRow = computed(() => { + if (mongoIndexManagerMode.value === "create" || mongoIndexManagerMode.value === "edit") return false; + const row = mongoIndexManagerSelected.value; + return !!row && !row.isProtected; + }); + + /** Editing requires a complete server specification so no unmodeled options are lost. */ + const canEditSelectedMongoIndexRow = computed(() => { + if (mongoIndexManagerMode.value === "create" || mongoIndexManagerMode.value === "edit") return false; + const row = mongoIndexManagerSelected.value; + return !!row && !row.isProtected && row.propertiesComplete; + }); + + /** Prefill the create form from the selected index row and enter edit mode. */ + function startEditMongoIndexDraft() { + const node = sidebarFormTarget.value ?? activeNode.value; + const row = mongoIndexManagerSelected.value; + if (!canEditSelectedMongoIndexRow.value || !row || !node.connectionId || !node.database) return; + const originalSpec = mongoIndexSpecsByName.get(row.name); + if (!originalSpec) return; + mongoCreateIndexForm.value = mongoCreateIndexFormFromRow(row); + mongoEditIndexOriginalName.value = row.name; + mongoEditIndexOriginalSpec = originalSpec; + mongoIndexManagerMode.value = "edit"; + mongoIndexManagerError.value = ""; + const collection = mongoIndexPanelCollectionName(node); + if (!collection) return; + void connectionStore + .listMongoCompletionFields(node.connectionId, node.database, collection) + .then((fields) => { + if (showMongoIndexManagerDialog.value) mongoCreateIndexFieldOptions.value = fields.map((field) => field.name); + }) + .catch(() => { + // MongoDB is schemaless; a field that was never sampled is still valid. + }); + } + + async function dropSelectedMongoIndexRow() { + const node = sidebarFormTarget.value ?? activeNode.value; + const connectionId = node.connectionId; + const database = node.database; + const collection = mongoIndexPanelCollectionName(node); + const row = mongoIndexManagerSelected.value; + if (!canDropSelectedMongoIndexRow.value || !row || !connectionId || !database || !collection) return; + + await runMongoSidebarMutation({ + connection: connectionStore.getConfig(connectionId), + database, + reviewText: mongoDropIndexPreview(database, collection, row.name), + source: t("production.sourceSidebar"), + loading: mongoIndexManagerLoading, + beforeExecute: () => connectionStore.ensureConnected(connectionId), + execute: () => api.mongoDropIndexes(connectionId, database, collection, JSON.stringify(row.name), true), + onSuccess: async (result) => { + const failed = mongoDropIndexFailureCount(result); + if (failed > 0) { + toast(t("contextMenu.dropIndexesPartialFailure", { success: result.dropped_names.length, failed }), 5000); + } else { + toast(t("contextMenu.dropTableChildObjectSuccess", { name: row.name }), 3000); + } + mongoIndexManagerSelectedName.value = ""; + await refreshMongoIndexTreeAfterMutation({ ...node, tableName: collection }); + await loadMongoIndexManagerRows(); + }, + onError: (error) => { + mongoIndexManagerError.value = translateBackendError(t, errorMessage(error)); + }, + }); + } + function addMongoCreateIndexField() { if (!mongoCreateIndexCanAddField.value) return; const nextId = Math.max(0, ...mongoCreateIndexForm.value.fields.map((field) => field.id)) + 1; @@ -297,16 +480,34 @@ export function useSidebarDatabaseSpecificMutationRuntime(options: SidebarDataba mongoCreateIndexForm.value.fields = mongoCreateIndexForm.value.fields.filter((field) => field.id !== id); } + function mongoCreateIndexRequestErrorText(request: Extract): string { + switch (request.error) { + case "field-duplicate": + return t("mongo.duplicateField", { field: request.field }); + case "ttl-invalid": + return t("contextMenu.createMongoIndexTtlInvalid"); + case "filter-invalid": + return t("contextMenu.createMongoIndexFilterInvalid"); + case "bucket-size-invalid": + return t("contextMenu.createMongoIndexBucketSizeInvalid"); + default: + return t("contextMenu.createMongoIndexFieldRequired"); + } + } + async function confirmCreateMongoIndex() { const node = sidebarFormTarget.value ?? activeNode.value; const connectionId = node.connectionId; const database = node.database; - const collectionName = mongoIndexCollectionName(node); - if (!canCreateMongoIndexNode(node) || !connectionId || !database || !collectionName) return; + // When called from the manager panel the node may be a collection node; + // when called from the standalone create dialog it is always a group-indexes node. + const collectionName = showMongoIndexManagerDialog.value ? mongoIndexPanelCollectionName(node) : mongoIndexCollectionName(node); + const canProceed = showMongoIndexManagerDialog.value ? canManageMongoIndexPanelNode(node) : canCreateMongoIndexNode(node); + if (!canProceed || !connectionId || !database || !collectionName) return; const request = buildMongoCreateIndexRequest(mongoCreateIndexForm.value); if (!request.valid) { - mongoCreateIndexError.value = request.error === "field-duplicate" ? t("mongo.duplicateField", { field: request.field }) : t("contextMenu.createMongoIndexFieldRequired"); + mongoCreateIndexError.value = mongoCreateIndexRequestErrorText(request); return; } @@ -322,6 +523,126 @@ export function useSidebarDatabaseSpecificMutationRuntime(options: SidebarDataba onSuccess: async (created) => { showCreateMongoIndexDialog.value = false; toast(t("contextMenu.createMongoIndexSuccess", { name: created.name, collection: collectionName }), 3000); + // In the manager panel the dialog stays open: return the property pane to + // read-only and reload the list so the new index shows up selected. + if (showMongoIndexManagerDialog.value) { + mongoIndexManagerMode.value = "view"; + mongoIndexManagerSelectedName.value = created.name; + resetMongoCreateIndexForm(); + await loadMongoIndexManagerRows(); + } + await refreshMongoIndexTreeAfterMutation({ ...node, tableName: collectionName }); + }, + onError: (error) => { + mongoCreateIndexError.value = translateBackendError(t, errorMessage(error)); + }, + }); + } + + /** + * Edit an existing index by drop + recreate. The form was prefilled by + * {@link startEditMongoIndexDraft}; this runs the two backend calls in + * sequence inside the production-gated mutation shell. + * + * Safety strategy: + * 1. Merge `extraOptions` (collation, wildcardProjection, weights, …) back + * into the create request so no server-reported option is silently lost. + * 2. Re-read the current index spec from the server before any destructive + * operation and compare it with the complete immutable opening snapshot. + * 3. When the user renamed the index, create the new one first, then drop + * the old one — if create fails the original index is untouched. + * 4. When the name is unchanged (same-name rebuild), preserve a complete + * rollback request and recreate the original index if the new build fails. + */ + async function confirmEditMongoIndex() { + const node = sidebarFormTarget.value ?? activeNode.value; + const connectionId = node.connectionId; + const database = node.database; + const collectionName = showMongoIndexManagerDialog.value ? mongoIndexPanelCollectionName(node) : mongoIndexCollectionName(node); + const originalName = mongoEditIndexOriginalName.value; + const originalSpec = mongoEditIndexOriginalSpec; + if (!connectionId || !database || !collectionName || !originalName || !originalSpec || mongoIndexManagerMode.value !== "edit") return; + + const request = buildMongoCreateIndexRequest(mongoCreateIndexForm.value); + if (!request.valid) { + mongoCreateIndexError.value = mongoCreateIndexRequestErrorText(request); + return; + } + + // Preserve server-reported options the form does not model (collation, + // wildcardProjection, weights, text defaults, geo options, …). + const merged = mergeExtraOptionsIntoRequest(request, originalSpec.extraOptions); + + const newName = mongoCreateIndexForm.value.name.trim(); + const isRename = newName && newName !== originalName; + if (!isRename && !originalSpec.propertiesComplete) { + mongoCreateIndexError.value = t("contextMenu.mongoEditIndexIncompleteSameName", { name: originalName }); + return; + } + + mongoCreateIndexError.value = ""; + await runMongoSidebarMutation({ + connection: connectionStore.getConfig(connectionId), + database, + // Review text shows both commands so production confirmation is explicit. + reviewText: mongoReplaceIndexPreview(database, collectionName, originalName, merged.keysJson, merged.optionsJson), + source: t("production.sourceSidebar"), + loading: mongoCreateIndexLoading, + beforeExecute: () => connectionStore.ensureConnected(connectionId), + execute: async () => { + // Re-read the current spec from the server to guard against concurrent + // modifications between when the dialog was opened and when the user + // confirmed the edit. + const specs = await api.mongoListIndexSpecs(connectionId, database, collectionName); + const preflight = preflightEditIndexSpec(specs, originalSpec); + if (!preflight.safe) { + throw new Error(preflight.reason === "not-found" ? t("contextMenu.mongoEditIndexNotFound", { name: originalName }) : t("contextMenu.mongoEditIndexStale", { name: originalName })); + } + + if (isRename) { + // Safe rename: create the new index first. If create fails, the + // original index is untouched and the error surfaces cleanly. + const created = await api.mongoCreateIndex(connectionId, database, collectionName, merged.keysJson, merged.optionsJson); + const dropResult = await api.mongoDropIndexes(connectionId, database, collectionName, JSON.stringify(originalName), true); + if (mongoDropIndexFailureCount(dropResult) > 0) { + const error = dropResult.failures?.map((failure) => `${failure.name}: ${failure.message}`).join("; ") || originalName; + throw new Error(t("contextMenu.mongoEditIndexDropFailedAfterCreate", { oldName: originalName, newName: created.name, error })); + } + return created; + } + + const rollbackRequest = mongoCreateIndexRequestFromSpec(originalSpec); + const dropResult = await api.mongoDropIndexes(connectionId, database, collectionName, JSON.stringify(originalName), true); + if (mongoDropIndexFailureCount(dropResult) > 0) { + const error = dropResult.failures?.map((failure) => `${failure.name}: ${failure.message}`).join("; ") || originalName; + throw new Error(t("contextMenu.mongoEditIndexDropFailed", { name: originalName, error })); + } + try { + return await api.mongoCreateIndex(connectionId, database, collectionName, merged.keysJson, merged.optionsJson); + } catch (createError) { + const createErrorMessage = errorMessage(createError); + try { + await api.mongoCreateIndex(connectionId, database, collectionName, rollbackRequest.keysJson, rollbackRequest.optionsJson); + } catch (rollbackError) { + throw new Error( + t("contextMenu.mongoEditIndexCreateAndRollbackFailed", { + name: originalName, + createError: createErrorMessage, + rollbackError: errorMessage(rollbackError), + }), + ); + } + throw new Error(t("contextMenu.mongoEditIndexCreateFailedRolledBack", { name: originalName, error: createErrorMessage })); + } + }, + onSuccess: async (created) => { + toast(t("contextMenu.mongoEditIndexSuccess", { oldName: originalName, newName: created.name, collection: collectionName }), 3000); + mongoIndexManagerMode.value = "view"; + mongoEditIndexOriginalName.value = ""; + mongoEditIndexOriginalSpec = undefined; + mongoIndexManagerSelectedName.value = created.name; + resetMongoCreateIndexForm(); + await loadMongoIndexManagerRows(); await refreshMongoIndexTreeAfterMutation({ ...node, tableName: collectionName }); }, onError: (error) => { @@ -640,6 +961,7 @@ export function useSidebarDatabaseSpecificMutationRuntime(options: SidebarDataba mongoDropAllIndexesPreview: mongoDropAllIndexesPreviewForNode, refreshMongoIndexTreeAfterMutation, canCreateMongoIndex, + canManageMongoIndexes, mongoIndexKeyTypes: MONGO_INDEX_KEY_TYPES, mongoCreateIndexCanSubmit, mongoCreateIndexCanAddField, @@ -647,6 +969,18 @@ export function useSidebarDatabaseSpecificMutationRuntime(options: SidebarDataba addMongoCreateIndexField, removeMongoCreateIndexField, confirmCreateMongoIndex, + prepareMongoIndexManagerDialog, + loadMongoIndexManagerRows, + mongoIndexManagerSelected, + mongoIndexManagerCollectionName, + selectMongoIndexRow, + startCreateMongoIndexDraft, + startEditMongoIndexDraft, + cancelMongoIndexDraft, + dropSelectedMongoIndexRow, + canDropSelectedMongoIndexRow, + canEditSelectedMongoIndexRow, + confirmEditMongoIndex, openCreateNacosNamespaceDialog, confirmCreateNacosNamespace, openEditNacosNamespaceDialog, diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 6d5498f8d6..eed4c8da71 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -2726,6 +2726,27 @@ export default { createMongoIndexNamePlaceholder: "Generated automatically", createMongoIndexNameHint: "Leave blank to let MongoDB generate the name from the fields and types.", createMongoIndexSparse: "Sparse", + createMongoIndexTtlInvalid: "Expire after must be a whole number of seconds.", + createMongoIndexFilterInvalid: 'Partial filter must be an object, for example { "status": "active" }.', + createMongoIndexBucketSizeInvalid: "Bucket size must be a whole number.", + manageMongoIndexes: "Manage Indexes", + manageMongoIndexesTitle: 'Indexes: "{collection}"', + mongoIndexKeys: "Keys", + mongoIndexEmpty: "This collection has no indexes yet.", + mongoIndexSelectHint: "Select an index to see its properties.", + mongoIndexReadOnlyHint: "MongoDB cannot alter an existing index. Drop it and create a new one to change its properties.", + editMongoIndex: "Edit Index", + saveMongoIndex: "Save", + mongoIndexRebuildHint: "Saving rebuilds the index: the old index is dropped and a new one is created.", + mongoEditIndexSuccess: 'Index "{oldName}" rebuilt as "{newName}" on "{collection}"', + mongoEditIndexNotFound: 'Index "{name}" no longer exists on the server. The list may be stale — refresh and try again.', + mongoEditIndexStale: 'Index "{name}" was modified by another session since the dialog was opened. Refresh the list and retry.', + mongoEditIndexIncompleteSameName: 'Index "{name}" does not have a complete server specification, so it cannot be rebuilt destructively with the same name.', + mongoEditIndexDropFailed: 'Index "{name}" could not be dropped; no replacement was created: {error}', + mongoEditIndexDropFailedAfterCreate: 'Index "{newName}" was created, but the original index "{oldName}" could not be dropped: {error}', + mongoEditIndexCreateFailedRolledBack: 'The replacement for index "{name}" could not be created, so the original index was restored: {error}', + mongoEditIndexCreateAndRollbackFailed: 'The replacement for index "{name}" could not be created ({createError}), and restoring the original index also failed ({rollbackError}).', + mongoIndexExtraOptionsPreserved: "Server-reported options (collation, wildcardProjection, weights, …) are preserved on rebuild.", dropForeignKey: "Drop Foreign Key", dropTrigger: "Drop Trigger", batchDrop: "Drop selected ({count})", @@ -4395,6 +4416,19 @@ export default { unsupportedJsonNumber: "JSON contains a number outside MongoDB int64 range", duplicateJsonKey: "Duplicate JSON key: {field}", duplicateField: "Duplicate field name: {field}", + indexExpireAfterSeconds: "Expire after (seconds)", + indexExpireAfterSecondsPlaceholder: "Never", + indexExpireAfterSecondsHint: "Single-field indexes only. MongoDB removes documents once the indexed date passes this age.", + indexPartialFilter: "Partial filter", + indexLegacyOptions: "Compatibility options", + indexLegacyOptionsHint: "Only useful on older servers: background is ignored from MongoDB 4.2, and bucket size stopped applying when geoHaystack indexes were removed in 4.4.", + indexBackground: "Background", + indexBucketSize: "Bucket size", + indexSparse: "Sparse", + indexHidden: "Hidden", + indexNoExpiry: "Never", + indexOtherOptions: "Other options", + indexPropertiesUnavailable: "The MongoDB Legacy driver cannot report sparse, expiry, background or bucket size. Connect with the native driver to see them.", edit: "Edit", editModeFields: "Fields", editModeJson: "JSON", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 53fd49bb5b..79a7e427fc 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -2673,6 +2673,27 @@ export default withEnglishFallback({ createMongoIndexNamePlaceholder: "Generado automáticamente", createMongoIndexNameHint: "Déjalo vacío para que MongoDB genere el nombre a partir de los campos y tipos.", createMongoIndexSparse: "Disperso", + createMongoIndexTtlInvalid: "El tiempo de expiración debe ser un número entero de segundos.", + createMongoIndexFilterInvalid: 'El filtro parcial debe ser un objeto, por ejemplo { "status": "active" }.', + createMongoIndexBucketSizeInvalid: "El tamaño del bucket debe ser un número entero.", + manageMongoIndexes: "Gestionar índices", + manageMongoIndexesTitle: 'Índices: "{collection}"', + mongoIndexKeys: "Claves", + mongoIndexEmpty: "Esta colección no tiene índices.", + mongoIndexSelectHint: "Selecciona un índice para ver sus propiedades.", + mongoIndexReadOnlyHint: "MongoDB no puede modificar un índice existente. Elimínalo y crea uno nuevo para cambiar sus propiedades.", + editMongoIndex: "Editar índice", + saveMongoIndex: "Guardar", + mongoIndexRebuildHint: "Guardar reconstruye el índice: se elimina el índice antiguo y se crea uno nuevo.", + mongoEditIndexSuccess: 'Índice "{oldName}" reconstruido como "{newName}" en "{collection}"', + mongoEditIndexNotFound: 'El índice "{name}" ya no existe en el servidor. La lista puede estar desactualizada; actualízala e inténtalo de nuevo.', + mongoEditIndexStale: 'El índice "{name}" fue modificado por otra sesión desde que se abrió el diálogo. Actualiza la lista e inténtalo de nuevo.', + mongoEditIndexIncompleteSameName: 'El índice "{name}" no tiene una especificación completa del servidor y no se puede reconstruir de forma destructiva con el mismo nombre.', + mongoEditIndexDropFailed: 'No se pudo eliminar el índice "{name}"; no se creó ningún reemplazo: {error}', + mongoEditIndexDropFailedAfterCreate: 'Se creó el índice "{newName}", pero no se pudo eliminar el índice original "{oldName}": {error}', + mongoEditIndexCreateFailedRolledBack: 'No se pudo crear el reemplazo del índice "{name}", por lo que se restauró el índice original: {error}', + mongoEditIndexCreateAndRollbackFailed: 'No se pudo crear el reemplazo del índice "{name}" ({createError}) y también falló la restauración del índice original ({rollbackError}).', + mongoIndexExtraOptionsPreserved: "Las opciones del servidor (collation, wildcardProjection, weights, …) se conservan al reconstruir.", batchDropIndexes: "Eliminar índices ({count})", confirmDropViewTitle: "Eliminar vista", confirmDropViewMessage: '¿Seguro que deseas eliminar la vista "{name}"?', @@ -4245,6 +4266,19 @@ export default withEnglishFallback({ nativeJsonHint: "Para los valores BSON, use JSON extendido de MongoDB, por ejemplo $oid, $date, $numberLong.", invalidNativeJson: "Formato JSON nativo no válido: {error}", nativeDocumentRequired: "El nivel superior del JSON nativo debe ser un objeto documento.", + indexExpireAfterSeconds: "Expirar después de (segundos)", + indexExpireAfterSecondsPlaceholder: "Nunca expira", + indexExpireAfterSecondsHint: "Solo índices de un solo campo. MongoDB elimina los documentos cuando la fecha indexada supera esta antigüedad.", + indexPartialFilter: "Filtro de índice parcial", + indexLegacyOptions: "Opciones de compatibilidad", + indexLegacyOptionsHint: "Solo relevante para versiones antiguas del servidor: MongoDB 4.2 ignora «background», y desde 4.4, tras eliminar los índices geoHaystack, «bucket size» ya no tiene efecto.", + indexBackground: "Background", + indexBucketSize: "Tamaño del bucket", + indexSparse: "Disperso", + indexHidden: "Oculto", + indexNoExpiry: "Nunca expira", + indexOtherOptions: "Otras opciones", + indexPropertiesUnavailable: "El controlador Legacy de MongoDB no puede informar disperso, expiración, background o tamaño de bucket. Conéctese con el controlador nativo para verlos.", }, vector: { collectionFallback: "colección", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 25d065ecc1..95037de0ca 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -2644,6 +2644,27 @@ export default withEnglishFallback({ createMongoIndexNamePlaceholder: "Generato automaticamente", createMongoIndexNameHint: "Lascia vuoto per fare generare il nome a MongoDB dai campi e dai tipi.", createMongoIndexSparse: "Sparso", + createMongoIndexTtlInvalid: "Il tempo di scadenza deve essere un numero intero di secondi.", + createMongoIndexFilterInvalid: 'Il filtro parziale deve essere un oggetto, ad esempio { "status": "active" }.', + createMongoIndexBucketSizeInvalid: "La dimensione del bucket deve essere un numero intero.", + manageMongoIndexes: "Gestisci indici", + manageMongoIndexesTitle: 'Indici: "{collection}"', + mongoIndexKeys: "Chiavi", + mongoIndexEmpty: "Questa raccolta non ha indici.", + mongoIndexSelectHint: "Seleziona un indice per vedere le sue proprietà.", + mongoIndexReadOnlyHint: "MongoDB non può modificare un indice esistente. Eliminalo e creane uno nuovo per cambiarne le proprietà.", + editMongoIndex: "Modifica indice", + saveMongoIndex: "Salva", + mongoIndexRebuildHint: "Il salvataggio ricostruisce l'indice: l'indice precedente viene eliminato e ne viene creato uno nuovo.", + mongoEditIndexSuccess: 'Indice "{oldName}" ricostruito come "{newName}" in "{collection}"', + mongoEditIndexNotFound: "L'indice \"{name}\" non esiste più sul server. L'elenco potrebbe non essere aggiornato; aggiorna e riprova.", + mongoEditIndexStale: "L'indice \"{name}\" è stato modificato da un'altra sessione da quando è stata aperta la finestra. Aggiorna l'elenco e riprova.", + mongoEditIndexIncompleteSameName: 'L\'indice "{name}" non dispone di una specifica completa del server e non può essere ricostruito in modo distruttivo con lo stesso nome.', + mongoEditIndexDropFailed: 'Impossibile eliminare l\'indice "{name}"; non è stato creato alcun sostituto: {error}', + mongoEditIndexDropFailedAfterCreate: 'L\'indice "{newName}" è stato creato, ma non è stato possibile eliminare l\'indice originale "{oldName}": {error}', + mongoEditIndexCreateFailedRolledBack: "Impossibile creare il sostituto dell'indice \"{name}\", quindi l'indice originale è stato ripristinato: {error}", + mongoEditIndexCreateAndRollbackFailed: "Impossibile creare il sostituto dell'indice \"{name}\" ({createError}) e anche il ripristino dell'indice originale non è riuscito ({rollbackError}).", + mongoIndexExtraOptionsPreserved: "Le opzioni del server (collation, wildcardProjection, weights, …) vengono preservate nella ricostruzione.", dropForeignKey: "Elimina Chiave Esterna", dropTrigger: "Elimina Trigger", batchDrop: "Elimina selezionati ({count})", @@ -4243,6 +4264,19 @@ export default withEnglishFallback({ nativeJsonHint: "Per i valori BSON, utilizzare MongoDB Extended JSON, ad esempio $oid, $date, $numberLong.", invalidNativeJson: "Formato JSON nativo non valido: {error}", nativeDocumentRequired: "Il livello superiore del JSON nativo deve essere un oggetto documento.", + indexExpireAfterSeconds: "Scadenza dopo (secondi)", + indexExpireAfterSecondsPlaceholder: "Mai", + indexExpireAfterSecondsHint: "Solo indici a campo singolo. MongoDB rimuove i documenti quando la data indicizzata supera questa età.", + indexPartialFilter: "Filtro parziale", + indexLegacyOptions: "Opzioni di compatibilità", + indexLegacyOptionsHint: "Rilevante solo per versioni vecchie del server: background è ignorato da MongoDB 4.2, e bucket size non si applica più dalla rimozione degli indici geoHaystack in 4.4.", + indexBackground: "Background", + indexBucketSize: "Dimensione bucket", + indexSparse: "Sparso", + indexHidden: "Nascosto", + indexNoExpiry: "Mai", + indexOtherOptions: "Altre opzioni", + indexPropertiesUnavailable: "Il driver Legacy di MongoDB non può riportare sparsità, scadenza, background o dimensione bucket. Connettersi con il driver nativo per vederli.", }, vector: { collectionFallback: "collezione", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 6bf2fd6c1a..6992817268 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -2694,6 +2694,27 @@ export default withEnglishFallback({ createMongoIndexNamePlaceholder: "自動生成", createMongoIndexNameHint: "空欄の場合、MongoDB がフィールドとタイプから名前を生成します。", createMongoIndexSparse: "スパース", + createMongoIndexTtlInvalid: "有効期限は秒単位の整数で指定してください。", + createMongoIndexFilterInvalid: '部分フィルターはオブジェクトである必要があります。例: { "status": "active" }', + createMongoIndexBucketSizeInvalid: "バケットサイズは整数である必要があります。", + manageMongoIndexes: "インデックスを管理", + manageMongoIndexesTitle: 'インデックス: "{collection}"', + mongoIndexKeys: "キー", + mongoIndexEmpty: "このコレクションにはインデックスがありません。", + mongoIndexSelectHint: "インデックスを選択してプロパティを表示します。", + mongoIndexReadOnlyHint: "MongoDB は既存のインデックスを変更できません。プロパティを変更するには削除して作成し直してください。", + editMongoIndex: "インデックスを編集", + saveMongoIndex: "保存", + mongoIndexRebuildHint: "保存するとインデックスが再構築されます: 古いインデックスが削除され、新しいインデックスが作成されます。", + mongoEditIndexSuccess: "インデックス「{oldName}」を「{newName}」として「{collection}」に再構築しました", + mongoEditIndexNotFound: "インデックス「{name}」はサーバーに存在しません。リストが古い可能性があります。更新して再試行してください。", + mongoEditIndexStale: "インデックス「{name}」はダイアログを開いてから別のセッションによって変更されました。リストを更新して再試行してください。", + mongoEditIndexIncompleteSameName: "インデックス「{name}」の完全なサーバー仕様を取得できないため、同じ名前で破壊的に再構築できません。", + mongoEditIndexDropFailed: "インデックス「{name}」を削除できなかったため、置換インデックスは作成されませんでした:{error}", + mongoEditIndexDropFailedAfterCreate: "新しいインデックス「{newName}」は作成されましたが、元のインデックス「{oldName}」を削除できませんでした:{error}", + mongoEditIndexCreateFailedRolledBack: "インデックス「{name}」の置換を作成できなかったため、元のインデックスを復元しました:{error}", + mongoEditIndexCreateAndRollbackFailed: "インデックス「{name}」の置換作成に失敗し({createError})、元のインデックスの復元にも失敗しました({rollbackError})。", + mongoIndexExtraOptionsPreserved: "サーバーから報告されたオプション(collation、wildcardProjection、weights など)は再構築時に保持されます。", batchDropIndexes: "インデックスを削除 ({count})", confirmDropViewTitle: "ビューを削除", confirmDropViewMessage: "本当にビュー「{name}」を削除しますか?", @@ -4273,6 +4294,19 @@ export default withEnglishFallback({ nativeJsonHint: "BSON 値には MongoDB Extended JSON を使用してください。例: $oid、$date、$numberLong。", invalidNativeJson: "ネイティブJSONの形式が無効です: {error}", nativeDocumentRequired: "ネイティブJSONの最上位はドキュメントオブジェクトである必要があります。", + indexExpireAfterSeconds: "有効期限(秒)", + indexExpireAfterSecondsPlaceholder: "期限なし", + indexExpireAfterSecondsHint: "単一フィールドインデックスのみ。インデックスの日付フィールドがこの経過時間を超えると、MongoDB は対応するドキュメントを削除します。", + indexPartialFilter: "部分フィルター", + indexLegacyOptions: "互換オプション", + indexLegacyOptionsHint: "旧バージョンのサーバーにのみ有効: MongoDB 4.2 以降は background を無視し、4.4 で geoHaystack インデックスが削除されてから bucket size は適用されなくなりました。", + indexBackground: "バックグラウンド", + indexBucketSize: "バケットサイズ", + indexSparse: "スパース", + indexHidden: "非表示", + indexNoExpiry: "期限なし", + indexOtherOptions: "その他のオプション", + indexPropertiesUnavailable: "MongoDB Legacy ドライバーはスパース、有効期限、バックグラウンド、バケットサイズを報告できません。ネイティブドライバーで接続して確認してください。", }, vector: { collectionFallback: "コレクション", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index cbae9830c9..61b3cf81ec 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -2572,6 +2572,27 @@ export default withEnglishFallback({ createMongoIndexNamePlaceholder: "자동 생성", createMongoIndexNameHint: "비워 두면 MongoDB가 필드와 유형으로 이름을 생성합니다.", createMongoIndexSparse: "희소", + createMongoIndexTtlInvalid: "만료 시간은 초 단위의 정수여야 합니다.", + createMongoIndexFilterInvalid: '부분 필터는 객체여야 합니다. 예: { "status": "active" }', + createMongoIndexBucketSizeInvalid: "버킷 크기는 정수여야 합니다.", + manageMongoIndexes: "인덱스 관리", + manageMongoIndexesTitle: '인덱스: "{collection}"', + mongoIndexKeys: "키", + mongoIndexEmpty: "이 컬렉션에 인덱스가 없습니다.", + mongoIndexSelectHint: "인덱스를 선택하여 속성을 확인하세요.", + mongoIndexReadOnlyHint: "MongoDB는 기존 인덱스를 수정할 수 없습니다. 속성을 변경하려면 삭제 후 새로 만드세요.", + editMongoIndex: "인덱스 편집", + saveMongoIndex: "저장", + mongoIndexRebuildHint: "저장하면 인덱스가 재구축됩니다: 기존 인덱스가 삭제되고 새 인덱스가 생성됩니다.", + mongoEditIndexSuccess: '인덱스 "{oldName}"이(가) "{collection}"에서 "{newName}"(으)로 재구축되었습니다', + mongoEditIndexNotFound: '인덱스 "{name}"이(가) 서버에 더 이상 존재하지 않습니다. 목록이 만료되었을 수 있습니다. 새로고침 후 다시 시도하세요.', + mongoEditIndexStale: '인덱스 "{name}"이(가) 대화상자를 연 이후 다른 세션에 의해 수정되었습니다. 목록을 새로고침 후 다시 시도하세요.', + mongoEditIndexIncompleteSameName: '인덱스 "{name}"의 전체 서버 사양을 가져올 수 없어 같은 이름으로 파괴적 재구축을 수행할 수 없습니다.', + mongoEditIndexDropFailed: '인덱스 "{name}"을(를) 삭제하지 못해 대체 인덱스를 생성하지 않았습니다: {error}', + mongoEditIndexDropFailedAfterCreate: '새 인덱스 "{newName}"을(를) 생성했지만 원래 인덱스 "{oldName}"을(를) 삭제하지 못했습니다: {error}', + mongoEditIndexCreateFailedRolledBack: '인덱스 "{name}"의 대체 인덱스를 생성하지 못해 원래 인덱스를 복원했습니다: {error}', + mongoEditIndexCreateAndRollbackFailed: '인덱스 "{name}"의 대체 인덱스 생성에 실패했고({createError}) 원래 인덱스 복원에도 실패했습니다({rollbackError}).', + mongoIndexExtraOptionsPreserved: "서버에서 보고한 옵션(collation, wildcardProjection, weights 등)은 재구축 시 보존됩니다.", dropForeignKey: "외래 키 삭제", dropTrigger: "트리거 삭제", batchDrop: "선택 항목 삭제 ({count})", @@ -3900,6 +3921,19 @@ export default withEnglishFallback({ tableView: "테이블 보기", filterPlaceholder: "필터...", sortPlaceholder: "정렬...", + indexExpireAfterSeconds: "만료 시간(초)", + indexExpireAfterSecondsPlaceholder: "만료 안 함", + indexExpireAfterSecondsHint: "단일 필드 인덱스만 해당. 인덱스 날짜 필드가 이 시간을 초과하면 MongoDB가 해당 문서를 삭제합니다.", + indexPartialFilter: "부분 필터", + indexLegacyOptions: "호환 옵션", + indexLegacyOptionsHint: "이전 서버 버전에만 의미: MongoDB 4.2부터 background가 무시되고, 4.4에서 geoHaystack 인덱스가 제거된 후 bucket size는 더 이상 적용되지 않습니다.", + indexBackground: "백그라운드", + indexBucketSize: "버킷 크기", + indexSparse: "희소", + indexHidden: "숨김", + indexNoExpiry: "만료 안 함", + indexOtherOptions: "기타 옵션", + indexPropertiesUnavailable: "MongoDB Legacy 드라이버는 희소, 만료, 백그라운드 또는 버킷 크기를 보고할 수 없습니다. 네이티브 드라이버로 연결하여 확인하세요.", }, vector: { collectionFallback: "컬렉션", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 5e3fe7ddbf..7703e7e4b0 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -2673,6 +2673,27 @@ export default withEnglishFallback({ createMongoIndexNamePlaceholder: "Gerado automaticamente", createMongoIndexNameHint: "Deixe em branco para o MongoDB gerar o nome com base nos campos e tipos.", createMongoIndexSparse: "Esparso", + createMongoIndexTtlInvalid: "O tempo de expiração deve ser um número inteiro de segundos.", + createMongoIndexFilterInvalid: 'O filtro parcial deve ser um objeto, por exemplo { "status": "active" }.', + createMongoIndexBucketSizeInvalid: "O tamanho do bucket deve ser um número inteiro.", + manageMongoIndexes: "Gerenciar índices", + manageMongoIndexesTitle: 'Índices: "{collection}"', + mongoIndexKeys: "Chaves", + mongoIndexEmpty: "Esta coleção não tem índices.", + mongoIndexSelectHint: "Selecione um índice para ver suas propriedades.", + mongoIndexReadOnlyHint: "O MongoDB não pode alterar um índice existente. Remova-o e crie um novo para alterar suas propriedades.", + editMongoIndex: "Editar índice", + saveMongoIndex: "Salvar", + mongoIndexRebuildHint: "Salvar reconstrói o índice: o índice antigo é removido e um novo é criado.", + mongoEditIndexSuccess: 'Índice "{oldName}" reconstruído como "{newName}" em "{collection}"', + mongoEditIndexNotFound: 'O índice "{name}" não existe mais no servidor. A lista pode estar desatualizada; atualize e tente novamente.', + mongoEditIndexStale: 'O índice "{name}" foi modificado por outra sessão desde que a caixa de diálogo foi aberta. Atualize a lista e tente novamente.', + mongoEditIndexIncompleteSameName: 'O índice "{name}" não possui uma especificação completa do servidor e não pode ser reconstruído de forma destrutiva com o mesmo nome.', + mongoEditIndexDropFailed: 'Não foi possível remover o índice "{name}"; nenhum substituto foi criado: {error}', + mongoEditIndexDropFailedAfterCreate: 'O índice "{newName}" foi criado, mas não foi possível remover o índice original "{oldName}": {error}', + mongoEditIndexCreateFailedRolledBack: 'Não foi possível criar o substituto do índice "{name}", portanto o índice original foi restaurado: {error}', + mongoEditIndexCreateAndRollbackFailed: 'Não foi possível criar o substituto do índice "{name}" ({createError}) e a restauração do índice original também falhou ({rollbackError}).', + mongoIndexExtraOptionsPreserved: "As opções relatadas pelo servidor (collation, wildcardProjection, weights, …) são preservadas na reconstrução.", batchDropIndexes: "Remover índices ({count})", confirmDropViewTitle: "Remover Visão", confirmDropViewMessage: 'Tem certeza de que deseja remover a visão "{name}"?', @@ -4245,6 +4266,19 @@ export default withEnglishFallback({ nativeJsonHint: "Para valores BSON, use MongoDB Extended JSON, por exemplo $oid, $date, $numberLong.", invalidNativeJson: "Formato de JSON nativo inválido: {error}", nativeDocumentRequired: "O objeto de nível superior do JSON nativo deve ser um documento.", + indexExpireAfterSeconds: "Expirar após (segundos)", + indexExpireAfterSecondsPlaceholder: "Nunca", + indexExpireAfterSecondsHint: "Apenas índices de campo único. O MongoDB remove documentos quando a data indexada ultrapassa esse tempo.", + indexPartialFilter: "Filtro parcial", + indexLegacyOptions: "Opções de compatibilidade", + indexLegacyOptionsHint: "Relevante apenas para versões antigas do servidor: o background é ignorado a partir do MongoDB 4.2, e o bucket size deixou de se aplicar quando os índices geoHaystack foram removidos no 4.4.", + indexBackground: "Background", + indexBucketSize: "Tamanho do bucket", + indexSparse: "Esparso", + indexHidden: "Oculto", + indexNoExpiry: "Nunca", + indexOtherOptions: "Outras opções", + indexPropertiesUnavailable: "O driver Legacy do MongoDB não pode informar esparso, expiração, background ou tamanho do bucket. Conecte-se com o driver nativo para vê-los.", }, vector: { collectionFallback: "coleção", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index d4d1cbdd46..ac58e35d1b 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -2650,6 +2650,27 @@ export default withEnglishFallback({ createMongoIndexNamePlaceholder: "自动生成", createMongoIndexNameHint: "留空时由 MongoDB 根据字段和类型自动生成名称。", createMongoIndexSparse: "稀疏", + createMongoIndexTtlInvalid: "过期时间必须是整数秒。", + createMongoIndexFilterInvalid: '部分索引过滤器必须是对象,例如 { "status": "active" }。', + createMongoIndexBucketSizeInvalid: "存储桶大小必须是整数。", + manageMongoIndexes: "管理索引", + manageMongoIndexesTitle: "索引:「{collection}」", + mongoIndexKeys: "键", + mongoIndexEmpty: "该集合暂无索引。", + mongoIndexSelectHint: "选择一个索引查看其属性。", + mongoIndexReadOnlyHint: "MongoDB 无法修改已有索引。如需更改属性,请删除后重新创建。", + editMongoIndex: "编辑索引", + saveMongoIndex: "保存", + mongoIndexRebuildHint: "保存会重建索引:旧索引将被删除并创建新索引。", + mongoEditIndexSuccess: "已在「{collection}」将索引「{oldName}」重建为「{newName}」", + mongoEditIndexNotFound: "索引「{name}」在服务器上已不存在。列表可能已过期,请刷新后重试。", + mongoEditIndexStale: "索引「{name}」自打开对话框以来已被其他会话修改。请刷新列表后重试。", + mongoEditIndexIncompleteSameName: "索引「{name}」缺少完整的服务器规格,无法执行同名破坏性重建。", + mongoEditIndexDropFailed: "索引「{name}」删除失败,未创建替代索引:{error}", + mongoEditIndexDropFailedAfterCreate: "新索引「{newName}」已创建,但原索引「{oldName}」删除失败:{error}", + mongoEditIndexCreateFailedRolledBack: "索引「{name}」的替代索引创建失败,已恢复原索引:{error}", + mongoEditIndexCreateAndRollbackFailed: "索引「{name}」的替代索引创建失败({createError}),恢复原索引也失败({rollbackError})。", + mongoIndexExtraOptionsPreserved: "服务器报告的选项(collation、wildcardProjection、weights 等)在重建时会被保留。", dropForeignKey: "删除外键", dropTrigger: "删除触发器", batchDrop: "删除所选({count})", @@ -4383,6 +4404,19 @@ export default withEnglishFallback({ unsupportedJsonNumber: "JSON 中包含超出 MongoDB int64 范围的数字", duplicateJsonKey: "JSON 字段重复:{field}", duplicateField: "字段名重复:{field}", + indexExpireAfterSeconds: "在此之后过期(秒)", + indexExpireAfterSecondsPlaceholder: "永不过期", + indexExpireAfterSecondsHint: "仅支持单字段索引。索引的日期字段超过该时长后,MongoDB 会删除对应文档。", + indexPartialFilter: "部分索引过滤器", + indexLegacyOptions: "兼容选项", + indexLegacyOptionsHint: "仅对旧版本服务器有意义:MongoDB 4.2 起忽略「背景」,4.4 移除 geoHaystack 索引后「存储桶大小」也不再生效。", + indexBackground: "背景", + indexBucketSize: "存储桶大小", + indexSparse: "稀疏", + indexHidden: "隐藏", + indexNoExpiry: "永不过期", + indexOtherOptions: "其他选项", + indexPropertiesUnavailable: "MongoDB 旧版驱动无法读取稀疏、过期时间、背景或存储桶大小,请使用原生驱动连接以查看这些属性。", edit: "编辑", editModeFields: "字段", editModeJson: "JSON", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 022a3b7ffb..9adc50ba58 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -2672,6 +2672,27 @@ export default withEnglishFallback({ createMongoIndexNamePlaceholder: "自動產生", createMongoIndexNameHint: "留空時由 MongoDB 根據欄位和類型自動產生名稱。", createMongoIndexSparse: "稀疏", + createMongoIndexTtlInvalid: "過期時間必須是整數秒。", + createMongoIndexFilterInvalid: '部分索引篩選條件必須是物件,例如 { "status": "active" }。', + createMongoIndexBucketSizeInvalid: "儲存桶大小必須是整數。", + manageMongoIndexes: "管理索引", + manageMongoIndexesTitle: "索引:「{collection}」", + mongoIndexKeys: "鍵", + mongoIndexEmpty: "該集合尚無索引。", + mongoIndexSelectHint: "選擇一個索引檢視其屬性。", + mongoIndexReadOnlyHint: "MongoDB 無法修改既有索引。如需變更屬性,請刪除後重新建立。", + editMongoIndex: "編輯索引", + saveMongoIndex: "儲存", + mongoIndexRebuildHint: "儲存會重建索引:舊索引將被刪除並建立新索引。", + mongoEditIndexSuccess: "已在「{collection}」將索引「{oldName}」重建為「{newName}」", + mongoEditIndexNotFound: "索引「{name}」在伺服器上已不存在。清單可能已過期,請重新整理後重試。", + mongoEditIndexStale: "索引「{name}」自開啟對話框以來已被其他工作階段修改。請重新整理清單後重試。", + mongoEditIndexIncompleteSameName: "索引「{name}」缺少完整的伺服器規格,無法執行同名破壞性重建。", + mongoEditIndexDropFailed: "索引「{name}」刪除失敗,未建立替代索引:{error}", + mongoEditIndexDropFailedAfterCreate: "新索引「{newName}」已建立,但原索引「{oldName}」刪除失敗:{error}", + mongoEditIndexCreateFailedRolledBack: "索引「{name}」的替代索引建立失敗,已還原原索引:{error}", + mongoEditIndexCreateAndRollbackFailed: "索引「{name}」的替代索引建立失敗({createError}),還原原索引也失敗({rollbackError})。", + mongoIndexExtraOptionsPreserved: "伺服器回報的選項(collation、wildcardProjection、weights 等)在重建時會被保留。", batchDropIndexes: "刪除索引({count})", confirmDropViewTitle: "刪除檢視", confirmDropViewMessage: "確定要刪除檢視「{name}」嗎?", @@ -3573,6 +3594,19 @@ export default withEnglishFallback({ unsupportedJsonNumber: "JSON 包含超出 MongoDB int64 範圍的數字", duplicateJsonKey: "JSON 欄位重複:{field}", duplicateField: "欄位名重複:{field}", + indexExpireAfterSeconds: "在此之後過期(秒)", + indexExpireAfterSecondsPlaceholder: "永不過期", + indexExpireAfterSecondsHint: "僅支援單欄位索引。索引的日期欄位超過該時長後,MongoDB 會刪除對應文件。", + indexPartialFilter: "部分索引篩選條件", + indexLegacyOptions: "相容選項", + indexLegacyOptionsHint: "僅對舊版伺服器有意義:MongoDB 4.2 起忽略「背景」,4.4 移除 geoHaystack 索引後「儲存桶大小」也不再生效。", + indexBackground: "背景", + indexBucketSize: "儲存桶大小", + indexSparse: "稀疏", + indexHidden: "隱藏", + indexNoExpiry: "永不過期", + indexOtherOptions: "其他選項", + indexPropertiesUnavailable: "MongoDB 舊版驅動無法讀取稀疏、過期時間、背景或儲存桶大小,請改用原生驅動連線以查看這些屬性。", edit: "編輯", editModeFields: "欄位", editModeJson: "JSON", diff --git a/apps/desktop/src/lib/backend/api.ts b/apps/desktop/src/lib/backend/api.ts index 5a228429c2..8a757fb9cb 100644 --- a/apps/desktop/src/lib/backend/api.ts +++ b/apps/desktop/src/lib/backend/api.ts @@ -689,6 +689,7 @@ export const mongoServerVersion = forward("mongoServerVersion"); export const mongoAggregateDocuments = forward("mongoAggregateDocuments"); export const mongoDistinct = forward("mongoDistinct"); export const mongoCollectionStats = forward("mongoCollectionStats"); +export const mongoListIndexSpecs = forward("mongoListIndexSpecs"); export const mongoCreateIndex = forward("mongoCreateIndex"); export const mongoCreateUser = forward("mongoCreateUser"); export const mongoRunCommand = forward("mongoRunCommand"); diff --git a/apps/desktop/src/lib/backend/http.ts b/apps/desktop/src/lib/backend/http.ts index 55ddf61b83..cd3b4de952 100644 --- a/apps/desktop/src/lib/backend/http.ts +++ b/apps/desktop/src/lib/backend/http.ts @@ -102,6 +102,7 @@ import type { MongoCollectionStatsResult, MongoCloneCollectionResult, MongoDropIndexesResult, + MongoIndexSpec, MongoGridFsBucketInfo, HistoryEntry, HistorySearchRequest, @@ -3625,6 +3626,14 @@ export async function mongoCollectionStats(connectionId: string, database: strin }); } +export async function mongoListIndexSpecs(connectionId: string, database: string, collection: string): Promise { + return post("/api/mongo/list-index-specs", { + connectionId, + database, + collection, + }); +} + export async function mongoCreateIndex(connectionId: string, database: string, collection: string, keysJson: string, optionsJson?: string): Promise<{ name: string }> { return post("/api/mongo/create-index", { connectionId, diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts index 4e29027a04..d462cf6104 100644 --- a/apps/desktop/src/lib/backend/tauri.ts +++ b/apps/desktop/src/lib/backend/tauri.ts @@ -3286,6 +3286,32 @@ export interface MongoDropIndexesResult { failures?: MongoDropIndexFailure[]; } +export interface MongoIndexKey { + field: string; + /** `1`, `-1`, or a MongoDB key type such as `text` / `2dsphere` / `hashed`. */ + direction: string; +} + +/** Full MongoDB index specification, carrying the options `IndexInfo` cannot hold. */ +export interface MongoIndexSpec { + name: string; + keys: MongoIndexKey[]; + is_unique: boolean; + is_primary: boolean; + is_sparse: boolean; + /** TTL in seconds; null when the index does not expire. */ + expire_after_seconds: number | null; + partial_filter_expression: string | null; + /** Ignored by MongoDB 4.2+, still reported by older servers. */ + background: boolean; + /** Only meaningful for geoHaystack indexes, removed in MongoDB 4.4+. */ + bucket_size: number | null; + hidden: boolean; + /** False when the driver could not report the properties above (Legacy Agent). */ + properties_complete: boolean; + extra_options: string | null; +} + export interface MongoCloneCollectionResult { documents_copied: number; indexes_copied: number; @@ -3538,6 +3564,14 @@ export async function mongoCollectionStats(connectionId: string, database: strin }); } +export async function mongoListIndexSpecs(connectionId: string, database: string, collection: string): Promise { + return invoke("mongo_list_index_specs", { + connectionId, + database, + collection, + }); +} + export async function mongoCreateIndex(connectionId: string, database: string, collection: string, keysJson: string, optionsJson?: string): Promise<{ name: string }> { return invoke("mongo_create_index", { connectionId, diff --git a/apps/desktop/src/lib/sidebar/__tests__/mongoCollectionMutation.spec.ts b/apps/desktop/src/lib/sidebar/__tests__/mongoCollectionMutation.spec.ts index 2e30d349f3..a0f7a92963 100644 --- a/apps/desktop/src/lib/sidebar/__tests__/mongoCollectionMutation.spec.ts +++ b/apps/desktop/src/lib/sidebar/__tests__/mongoCollectionMutation.spec.ts @@ -4,21 +4,48 @@ import { isCloneableMongoCollection, isProtectedMongoIndex, isRenamableMongoCollection, + mergeExtraOptionsIntoRequest, mongoCollectionKindFromNode, mongoCollectionTableTypeFromNode, mongoCloneCollectionPreview, + mongoCreateIndexFormFromRow, + mongoCreateIndexRequestFromSpec, mongoCreateIndexPreview, mongoDropCollectionPreview, mongoDropAllIndexesPreview, mongoDropIndexFailureCount, mongoDropIndexPreview, + mongoIndexKeyLabel, mongoRenameCollectionPreview, + mongoReplaceIndexPreview, + preflightEditIndexSpec, + snapshotMongoIndexSpec, toMongoCollectionKind, + toMongoIndexRow, type MongoCreateIndexForm, + type MongoIndexSpecSource, } from "../mongoCollectionMutation"; function indexForm(fields: MongoCreateIndexForm["fields"], options: Partial> = {}): MongoCreateIndexForm { - return { name: "", unique: false, sparse: false, ...options, fields }; + return { name: "", unique: false, sparse: false, expireAfterSeconds: "", partialFilterExpression: "", background: false, bucketSize: "", ...options, fields }; +} + +function serverIndexSpec(overrides: Partial = {}): MongoIndexSpecSource { + return { + name: "email_1", + keys: [{ field: "email", direction: "1" }], + is_unique: false, + is_primary: false, + is_sparse: true, + expire_after_seconds: 3600, + partial_filter_expression: '{"archived":false}', + background: false, + bucket_size: null, + hidden: false, + properties_complete: true, + extra_options: '{"collation":{"locale":"en"}}', + ...overrides, + }; } describe("isRenamableMongoCollection", () => { @@ -156,4 +183,319 @@ describe("mongo shell previews", () => { ), ).toEqual({ valid: false, error: "field-duplicate", field: "email" }); }); + + it("emits TTL and partial-filter options in a stable order", () => { + const request = buildMongoCreateIndexRequest( + indexForm([{ id: 1, path: "createdAt", type: "1" }], { + name: "ttl_idx", + unique: true, + sparse: true, + expireAfterSeconds: "3600", + partialFilterExpression: '{"archived":false}', + }), + ); + + expect(request).toEqual({ + valid: true, + keysJson: '{"createdAt":1}', + optionsJson: '{"name":"ttl_idx","unique":true,"sparse":true,"expireAfterSeconds":3600,"partialFilterExpression":{"archived":false}}', + }); + }); + + it("keeps a zero-second TTL distinct from an empty box", () => { + const withZero = buildMongoCreateIndexRequest(indexForm([{ id: 1, path: "createdAt", type: "1" }], { expireAfterSeconds: "0" })); + expect(withZero).toEqual({ valid: true, keysJson: '{"createdAt":1}', optionsJson: '{"expireAfterSeconds":0}' }); + + const withBlank = buildMongoCreateIndexRequest(indexForm([{ id: 1, path: "createdAt", type: "1" }], { expireAfterSeconds: " " })); + expect(withBlank).toEqual({ valid: true, keysJson: '{"createdAt":1}', optionsJson: undefined }); + }); + + it("passes legacy options through only when enabled", () => { + const request = buildMongoCreateIndexRequest(indexForm([{ id: 1, path: "email", type: "1" }], { background: true, bucketSize: "10" })); + expect(request).toEqual({ valid: true, keysJson: '{"email":1}', optionsJson: '{"background":true,"bucketSize":10}' }); + }); + + it("rejects a non-numeric TTL, a bad bucket size, and a non-object filter", () => { + expect(buildMongoCreateIndexRequest(indexForm([{ id: 1, path: "a", type: "1" }], { expireAfterSeconds: "-5" }))).toEqual({ valid: false, error: "ttl-invalid" }); + expect(buildMongoCreateIndexRequest(indexForm([{ id: 1, path: "a", type: "1" }], { expireAfterSeconds: "1.5" }))).toEqual({ valid: false, error: "ttl-invalid" }); + expect(buildMongoCreateIndexRequest(indexForm([{ id: 1, path: "a", type: "1" }], { bucketSize: "abc" }))).toEqual({ valid: false, error: "bucket-size-invalid" }); + expect(buildMongoCreateIndexRequest(indexForm([{ id: 1, path: "a", type: "1" }], { partialFilterExpression: "{not valid" }))).toEqual({ valid: false, error: "filter-invalid" }); + expect(buildMongoCreateIndexRequest(indexForm([{ id: 1, path: "a", type: "1" }], { partialFilterExpression: "[1,2]" }))).toEqual({ valid: false, error: "filter-invalid" }); + }); + + it("reports a missing field before a malformed option", () => { + expect(buildMongoCreateIndexRequest(indexForm([{ id: 1, path: "", type: "1" }], { expireAfterSeconds: "nope" }))).toEqual({ valid: false, error: "field-required" }); + }); +}); + +describe("mongoIndexKeyLabel", () => { + it("maps sort directions to readable labels and passes other types through", () => { + expect(mongoIndexKeyLabel(1)).toBe("ASC"); + expect(mongoIndexKeyLabel("1")).toBe("ASC"); + expect(mongoIndexKeyLabel(-1)).toBe("DESC"); + expect(mongoIndexKeyLabel("-1")).toBe("DESC"); + expect(mongoIndexKeyLabel("text")).toBe("text"); + expect(mongoIndexKeyLabel(undefined)).toBe(""); + }); +}); + +describe("toMongoIndexRow", () => { + it("describes compound keys from a spec with directions", () => { + expect( + toMongoIndexRow({ + name: "account_created", + keys: [ + { field: "account", direction: "1" }, + { field: "createTime", direction: "-1" }, + ], + }), + ).toEqual({ + name: "account_created", + keys: "account ASC, createTime DESC", + isUnique: false, + isProtected: false, + isSparse: false, + expireAfterSeconds: undefined, + partialFilterExpression: undefined, + background: false, + bucketSize: undefined, + hidden: false, + propertiesComplete: true, + extraOptions: undefined, + }); + }); + + it("keeps non-numeric directions literal", () => { + expect( + toMongoIndexRow({ + name: "content_text", + keys: [{ field: "content", direction: "text" }], + }).keys, + ).toBe("content text"); + }); + + it("maps spec properties and flags the default index as protected", () => { + const row = toMongoIndexRow({ + name: "_id_", + keys: [{ field: "_id", direction: "1" }], + is_unique: true, + is_primary: true, + is_sparse: true, + expire_after_seconds: 3600, + partial_filter_expression: '{"archived":false}', + background: true, + bucket_size: 32, + hidden: true, + properties_complete: true, + extra_options: '{"collation":{"locale":"en"}}', + }); + + expect(row.isProtected).toBe(true); + expect(row.isSparse).toBe(true); + expect(row.expireAfterSeconds).toBe(3600); + expect(row.partialFilterExpression).toBe('{"archived":false}'); + expect(row.background).toBe(true); + expect(row.bucketSize).toBe(32); + expect(row.hidden).toBe(true); + expect(row.propertiesComplete).toBe(true); + expect(row.extraOptions).toBe('{"collation":{"locale":"en"}}'); + }); + + it("exposes the Legacy Agent's incomplete property set", () => { + const row = toMongoIndexRow({ name: "email_1", keys: [{ field: "email", direction: "1" }], properties_complete: false }); + + expect(row.propertiesComplete).toBe(false); + expect(row.isSparse).toBe(false); + expect(row.expireAfterSeconds).toBeUndefined(); + }); +}); + +describe("mongoCreateIndexFormFromRow", () => { + it("parses compound keys back into form fields with directions", () => { + const row = toMongoIndexRow({ + name: "account_created", + keys: [ + { field: "account", direction: "1" }, + { field: "createTime", direction: "-1" }, + ], + }); + const form = mongoCreateIndexFormFromRow(row); + + expect(form.name).toBe("account_created"); + expect(form.fields).toEqual([ + { id: 1, path: "account", type: "1" }, + { id: 2, path: "createTime", type: "-1" }, + ]); + }); + + it("round-trips non-numeric key types through the label parser", () => { + const row = toMongoIndexRow({ name: "content_text", keys: [{ field: "content", direction: "text" }] }); + const form = mongoCreateIndexFormFromRow(row); + + expect(form.fields).toEqual([{ id: 1, path: "content", type: "text" }]); + }); + + it("maps every option field back onto the form", () => { + const row = toMongoIndexRow({ + name: "ttl_idx", + keys: [{ field: "createdAt", direction: "1" }], + is_unique: true, + is_sparse: true, + expire_after_seconds: 3600, + partial_filter_expression: '{"archived":false}', + background: true, + bucket_size: 32, + }); + const form = mongoCreateIndexFormFromRow(row); + + expect(form.unique).toBe(true); + expect(form.sparse).toBe(true); + expect(form.expireAfterSeconds).toBe("3600"); + expect(form.partialFilterExpression).toBe('{"archived":false}'); + expect(form.background).toBe(true); + expect(form.bucketSize).toBe("32"); + }); + + it("falls back to one ascending field when the keys description is empty", () => { + const row = toMongoIndexRow({ name: "empty" }); + const form = mongoCreateIndexFormFromRow(row); + + expect(form.fields).toEqual([{ id: 1, path: "", type: "1" }]); + }); +}); + +describe("mongoReplaceIndexPreview", () => { + it("joins the drop and create commands for review", () => { + const preview = mongoReplaceIndexPreview("app", "users", "email_1", '{"email":1}', '{"unique":true}'); + const drop = mongoDropIndexPreview("app", "users", "email_1"); + const create = mongoCreateIndexPreview("app", "users", '{"email":1}', '{"unique":true}'); + + expect(preview).toBe(`${drop}\n${create}`); + expect(preview).toContain('dropIndex("email_1")'); + expect(preview).toContain('createIndex({"email":1}, {"unique":true})'); + }); +}); + +describe("mongoCreateIndexFormFromRow hidden", () => { + it("preserves the hidden flag from the row", () => { + const row = toMongoIndexRow({ name: "hidden_idx", keys: [{ field: "x", direction: "1" }], hidden: true }); + const form = mongoCreateIndexFormFromRow(row); + expect(form.hidden).toBe(true); + }); + + it("defaults hidden to false when the row does not report it", () => { + const row = toMongoIndexRow({ name: "visible_idx", keys: [{ field: "x", direction: "1" }] }); + const form = mongoCreateIndexFormFromRow(row); + expect(form.hidden).toBe(false); + }); +}); + +describe("buildMongoCreateIndexRequest hidden", () => { + it("emits hidden:true when the form sets it", () => { + const request = buildMongoCreateIndexRequest(indexForm([{ id: 1, path: "x", type: "1" }], { hidden: true })); + expect(request).toEqual({ valid: true, keysJson: '{"x":1}', optionsJson: '{"hidden":true}' }); + }); + + it("omits hidden when the form leaves it false", () => { + const request = buildMongoCreateIndexRequest(indexForm([{ id: 1, path: "x", type: "1" }], { hidden: false })); + expect(request).toEqual({ valid: true, keysJson: '{"x":1}', optionsJson: undefined }); + }); +}); + +describe("mergeExtraOptionsIntoRequest", () => { + it("merges collation and wildcardProjection from extraOptions into the request", () => { + const request = { valid: true as const, keysJson: '{"x":1}', optionsJson: '{"name":"x_1"}' }; + const extra = '{"collation":{"locale":"en"},"wildcardProjection":{"x":1}}'; + const merged = mergeExtraOptionsIntoRequest(request, extra); + const parsed = JSON.parse(merged.optionsJson!); + expect(parsed.collation).toEqual({ locale: "en" }); + expect(parsed.wildcardProjection).toEqual({ x: 1 }); + expect(parsed.name).toBe("x_1"); + }); + + it("form options take precedence over extraOptions for the same key", () => { + const request = { valid: true as const, keysJson: '{"x":1}', optionsJson: '{"name":"renamed"}' }; + const extra = '{"name":"original","weights":{"content":2}}'; + const merged = mergeExtraOptionsIntoRequest(request, extra); + const parsed = JSON.parse(merged.optionsJson!); + expect(parsed.name).toBe("renamed"); + expect(parsed.weights).toEqual({ content: 2 }); + }); + + it("filters out a stray key field from extraOptions", () => { + const request = { valid: true as const, keysJson: '{"x":1}', optionsJson: undefined }; + const extra = '{"key":{"y":1},"collation":{"locale":"ja"}}'; + const merged = mergeExtraOptionsIntoRequest(request, extra); + const parsed = JSON.parse(merged.optionsJson!); + expect(parsed.key).toBeUndefined(); + expect(parsed.collation).toEqual({ locale: "ja" }); + }); + + it("falls back to the form-only request when extraOptions is malformed", () => { + const request = { valid: true as const, keysJson: '{"x":1}', optionsJson: '{"unique":true}' }; + const merged = mergeExtraOptionsIntoRequest(request, "{not valid json"); + expect(merged.optionsJson).toBe('{"unique":true}'); + }); + + it("returns the form request unchanged when extraOptions is empty", () => { + const request = { valid: true as const, keysJson: '{"x":1}', optionsJson: '{"unique":true}' }; + const merged = mergeExtraOptionsIntoRequest(request, ""); + expect(merged.optionsJson).toBe('{"unique":true}'); + }); +}); + +describe("preflightEditIndexSpec", () => { + it("reports safe when the complete normalized specification matches", () => { + const original = snapshotMongoIndexSpec(serverIndexSpec()); + const current = serverIndexSpec({ + partial_filter_expression: '{ "archived": false }', + extra_options: '{"collation":{"locale":"en"}}', + }); + expect(preflightEditIndexSpec([current], original)).toEqual({ safe: true }); + }); + + it("reports not-found when the index no longer exists", () => { + const original = snapshotMongoIndexSpec(serverIndexSpec()); + expect(preflightEditIndexSpec([serverIndexSpec({ name: "other" })], original)).toEqual({ safe: false, reason: "not-found" }); + }); + + it("reports stale when the keys have changed since the dialog was opened", () => { + const original = snapshotMongoIndexSpec(serverIndexSpec()); + expect(preflightEditIndexSpec([serverIndexSpec({ keys: [{ field: "email", direction: "-1" }] })], original)).toEqual({ safe: false, reason: "stale" }); + }); + + it("reports stale when the keys match but any complete option changed", () => { + const original = snapshotMongoIndexSpec(serverIndexSpec()); + expect(preflightEditIndexSpec([serverIndexSpec({ is_unique: true, hidden: true })], original)).toEqual({ safe: false, reason: "stale" }); + expect(preflightEditIndexSpec([serverIndexSpec({ extra_options: '{"collation":{"locale":"fr"}}' })], original)).toEqual({ safe: false, reason: "stale" }); + }); +}); + +describe("mongoCreateIndexRequestFromSpec", () => { + it("builds a complete rollback request from the opening server specification", () => { + const snapshot = snapshotMongoIndexSpec( + serverIndexSpec({ + is_unique: true, + background: true, + bucket_size: 16, + hidden: true, + extra_options: '{"collation":{"locale":"en"},"wildcardProjection":{"email":1}}', + }), + ); + + const request = mongoCreateIndexRequestFromSpec(snapshot); + expect(request.keysJson).toBe('{"email":1}'); + expect(JSON.parse(request.optionsJson)).toEqual({ + collation: { locale: "en" }, + wildcardProjection: { email: 1 }, + name: "email_1", + unique: true, + sparse: true, + expireAfterSeconds: 3600, + partialFilterExpression: { archived: false }, + background: true, + bucketSize: 16, + hidden: true, + }); + }); }); diff --git a/apps/desktop/src/lib/sidebar/mongoCollectionMutation.ts b/apps/desktop/src/lib/sidebar/mongoCollectionMutation.ts index bee032cdd4..af358b9d96 100644 --- a/apps/desktop/src/lib/sidebar/mongoCollectionMutation.ts +++ b/apps/desktop/src/lib/sidebar/mongoCollectionMutation.ts @@ -15,6 +15,16 @@ export interface MongoCreateIndexForm { fields: MongoCreateIndexField[]; unique: boolean; sparse: boolean; + /** TTL in seconds. Kept as text so an empty box stays distinct from `0`. */ + expireAfterSeconds: string; + /** Partial index condition, entered as an object literal. */ + partialFilterExpression: string; + /** Ignored by MongoDB 4.2+; retained for older servers. */ + background: boolean; + /** Only meaningful for geoHaystack indexes, removed in MongoDB 4.4+. */ + bucketSize: string; + /** Hide the index from the query planner (MongoDB 4.4+). */ + hidden: boolean; } export type MongoCreateIndexRequest = @@ -25,7 +35,7 @@ export type MongoCreateIndexRequest = } | { valid: false; - error: "field-required" | "field-duplicate"; + error: "field-required" | "field-duplicate" | "ttl-invalid" | "filter-invalid" | "bucket-size-invalid"; field?: string; }; @@ -108,6 +118,28 @@ export function isProtectedMongoIndex(index: { name: string; is_primary?: boolea return index.name === "_id_" || !!index.is_primary; } +/** Parse an optional non-negative integer box; `null` marks a malformed entry. */ +function optionalNonNegativeInteger(raw: string | undefined): number | undefined | null { + const value = raw?.trim(); + if (!value) return undefined; + if (!/^\d+$/.test(value)) return null; + const parsed = Number(value); + return Number.isSafeInteger(parsed) ? parsed : null; +} + +/** Parse the partial filter box; `null` marks anything that is not an object literal. */ +function optionalFilterObject(raw: string | undefined): Record | undefined | null { + const value = raw?.trim(); + if (!value) return undefined; + try { + const parsed = JSON.parse(value); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null; + return parsed as Record; + } catch { + return null; + } +} + /** 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() })); @@ -121,15 +153,177 @@ export function buildMongoCreateIndexRequest(form: MongoCreateIndexForm): MongoC // 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 expireAfterSeconds = optionalNonNegativeInteger(form.expireAfterSeconds); + if (expireAfterSeconds === null) return { valid: false, error: "ttl-invalid" }; + const bucketSize = optionalNonNegativeInteger(form.bucketSize); + if (bucketSize === null) return { valid: false, error: "bucket-size-invalid" }; + const partialFilterExpression = optionalFilterObject(form.partialFilterExpression); + if (partialFilterExpression === null) return { valid: false, error: "filter-invalid" }; + const options = { ...(form.name.trim() ? { name: form.name.trim() } : {}), ...(form.unique ? { unique: true } : {}), ...(form.sparse ? { sparse: true } : {}), + ...(expireAfterSeconds === undefined ? {} : { expireAfterSeconds }), + ...(partialFilterExpression === undefined ? {} : { partialFilterExpression }), + ...(form.background ? { background: true } : {}), + ...(bucketSize === undefined ? {} : { bucketSize }), + ...(form.hidden ? { hidden: true } : {}), }; const optionsJson = Object.keys(options).length ? JSON.stringify(options) : undefined; return { valid: true, keysJson, optionsJson }; } +/** + * Merge the server-reported `extraOptions` (collation, wildcardProjection, + * weights, text defaults, geo options, …) into the form-built request so the + * drop + recreate preserves every option the form does not model. Form values + * take precedence: if the user explicitly set `unique` in the form, that wins + * over any `unique` in `extraOptions` (which shouldn't appear there anyway + * since it's modeled, but the principle holds for overlapping future fields). + */ +export function mergeExtraOptionsIntoRequest(request: Extract, extraOptions?: string): { keysJson: string; optionsJson?: string } { + if (!extraOptions?.trim()) return { keysJson: request.keysJson, optionsJson: request.optionsJson }; + let extra: Record; + try { + const parsed = JSON.parse(extraOptions); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return request; + extra = parsed as Record; + } catch { + // Malformed extra options should not block the edit; fall back to form-only. + return request; + } + // `key` is the index key spec, never an option — filter it defensively. + delete extra.key; + const formOptions = request.optionsJson ? (JSON.parse(request.optionsJson) as Record) : {}; + // Form values take precedence over extraOptions for the same key. + const merged = { ...extra, ...formOptions }; + const optionsJson = Object.keys(merged).length ? JSON.stringify(merged) : undefined; + return { keysJson: request.keysJson, optionsJson }; +} + +export type MongoIndexSpecSource = { + name: string; + keys?: readonly { field: string; direction: string }[] | null; + is_unique?: boolean; + is_primary?: boolean; + is_sparse?: boolean; + expire_after_seconds?: number | null; + partial_filter_expression?: string | null; + background?: boolean; + bucket_size?: number | null; + hidden?: boolean; + properties_complete?: boolean; + extra_options?: string | null; +}; + +/** Immutable, normalized server specification captured when index editing starts. */ +export interface MongoIndexSpecSnapshot { + name: string; + keys: { field: string; direction: string }[]; + isUnique: boolean; + isPrimary: boolean; + isSparse: boolean; + expireAfterSeconds: number | null; + partialFilterExpression?: string; + background: boolean; + bucketSize: number | null; + hidden: boolean; + propertiesComplete: boolean; + extraOptions?: string; +} + +/** Copy a server result into the stable shape used by preflight and rollback. */ +export function snapshotMongoIndexSpec(source: MongoIndexSpecSource): MongoIndexSpecSnapshot { + return { + name: source.name, + keys: (source.keys ?? []).map((key) => ({ field: key.field, direction: String(key.direction) })), + isUnique: !!source.is_unique, + isPrimary: !!source.is_primary, + isSparse: !!source.is_sparse, + expireAfterSeconds: source.expire_after_seconds ?? null, + partialFilterExpression: source.partial_filter_expression?.trim() || undefined, + background: !!source.background, + bucketSize: source.bucket_size ?? null, + hidden: !!source.hidden, + propertiesComplete: source.properties_complete !== false, + extraOptions: source.extra_options?.trim() || undefined, + }; +} + +function canonicalJsonValue(value: unknown): unknown { + if (Array.isArray(value)) return value.map(canonicalJsonValue); + if (!value || typeof value !== "object") return value; + return Object.fromEntries( + Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, child]) => [key, canonicalJsonValue(child)]), + ); +} + +function canonicalJsonText(raw?: string): string | undefined { + if (!raw) return undefined; + try { + return JSON.stringify(canonicalJsonValue(JSON.parse(raw))); + } catch { + return raw.trim() || undefined; + } +} + +function comparableMongoIndexSpec(spec: MongoIndexSpecSnapshot): unknown { + return { + ...spec, + partialFilterExpression: canonicalJsonText(spec.partialFilterExpression), + extraOptions: canonicalJsonText(spec.extraOptions), + }; +} + +/** + * Preflight check before a destructive edit: verify the complete normalized + * server specification still matches the immutable snapshot captured when the + * editor opened. + */ +export function preflightEditIndexSpec(currentSpecs: readonly MongoIndexSpecSource[], originalSpec: MongoIndexSpecSnapshot): { safe: boolean; reason?: string } { + const current = currentSpecs.find((spec) => spec.name === originalSpec.name); + if (!current) return { safe: false, reason: "not-found" }; + const currentSnapshot = snapshotMongoIndexSpec(current); + if (JSON.stringify(comparableMongoIndexSpec(currentSnapshot)) !== JSON.stringify(comparableMongoIndexSpec(originalSpec))) { + return { safe: false, reason: "stale" }; + } + return { safe: true }; +} + +function parseIndexOptionObject(raw: string | undefined, label: string): Record { + if (!raw) return {}; + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error(`${label} must be a JSON object`); + } + return parsed as Record; +} + +/** Rebuild the exact create-index transport request used to restore an original index. */ +export function mongoCreateIndexRequestFromSpec(spec: MongoIndexSpecSnapshot): { keysJson: string; optionsJson: string } { + const keysJson = `{${spec.keys.map((key) => `${JSON.stringify(key.field)}:${JSON.stringify(key.direction === "1" ? 1 : key.direction === "-1" ? -1 : key.direction)}`).join(",")}}`; + const extraOptions = parseIndexOptionObject(spec.extraOptions, "Index extra options"); + for (const modeledField of ["key", "name", "unique", "sparse", "expireAfterSeconds", "partialFilterExpression", "background", "bucketSize", "hidden"]) { + delete extraOptions[modeledField]; + } + const partialFilterExpression = spec.partialFilterExpression ? parseIndexOptionObject(spec.partialFilterExpression, "Partial filter expression") : undefined; + const options = { + ...extraOptions, + name: spec.name, + ...(spec.isUnique ? { unique: true } : {}), + ...(spec.isSparse ? { sparse: true } : {}), + ...(spec.expireAfterSeconds === null ? {} : { expireAfterSeconds: spec.expireAfterSeconds }), + ...(partialFilterExpression ? { partialFilterExpression } : {}), + ...(spec.background ? { background: true } : {}), + ...(spec.bucketSize === null ? {} : { bucketSize: spec.bucketSize }), + ...(spec.hidden ? { hidden: true } : {}), + }; + return { keysJson, optionsJson: JSON.stringify(options) }; +} + /** * Keep the preview and production confirmation byte-for-byte aligned with * the JSON passed to the backend. Re-serializing parsed JSON could reorder a @@ -139,3 +333,127 @@ export function mongoCreateIndexPreview(database: string, collection: string, ke const args = [keysJson, ...(optionsJson ? [optionsJson] : [])]; return `db.getSiblingDB(${JSON.stringify(database)}).getCollection(${JSON.stringify(collection)}).createIndex(${args.join(", ")})`; } + +/** Render a key direction the way index management tools label it. */ +export function mongoIndexKeyLabel(value: unknown): string { + if (value === 1 || value === "1") return "ASC"; + if (value === -1 || value === "-1") return "DESC"; + return String(value ?? ""); +} + +/** + * Reverse {@link mongoIndexKeyLabel} so an edited index can re-enter the form + * with the same direction the server reported. Anything that is not ASC/DESC + * (text, 2dsphere, hashed, …) is kept verbatim. + */ +function mongoIndexKeyTypeFromLabel(label: string): MongoIndexKeyType { + if (label === "ASC") return "1"; + if (label === "DESC") return "-1"; + // Non-numeric key types (text, 2dsphere, hashed, 2d) round-trip as-is when + // they are part of the known set, otherwise default to ascending. + return (MONGO_INDEX_KEY_TYPES as readonly string[]).includes(label) ? (label as MongoIndexKeyType) : "1"; +} + +/** + * Parse the human-readable keys description produced by {@link mongoIndexKeyDescription} + * back into the visual form's field rows. Each comma-separated segment is + * `field DIRECTION`; a segment with no direction defaults to ascending. + */ +function parseMongoIndexKeyDescription(description: string): { field: string; type: MongoIndexKeyType }[] { + return description + .split(",") + .map((segment) => segment.trim()) + .filter((segment) => segment.length > 0) + .map((segment) => { + const spaceIndex = segment.lastIndexOf(" "); + // Segments like `account ASC` split into field + label; a bare field name + // (no direction) falls back to ascending. + if (spaceIndex <= 0) return { field: segment, type: "1" as MongoIndexKeyType }; + const field = segment.slice(0, spaceIndex).trim(); + const label = segment.slice(spaceIndex + 1).trim(); + return { field, type: mongoIndexKeyTypeFromLabel(label) }; + }); +} + +/** One row of the index management panel. */ +export interface MongoIndexRow { + name: string; + /** Per-key description, e.g. `account ASC`. */ + keys: string; + isUnique: boolean; + isProtected: boolean; + isSparse: boolean; + /** TTL in seconds; undefined when the index does not expire. */ + expireAfterSeconds?: number; + partialFilterExpression?: string; + background: boolean; + bucketSize?: number; + hidden: boolean; + /** + * False when the driver could not report the properties above, so the panel + * hides them instead of presenting defaults as if the server had said so. + */ + propertiesComplete: boolean; + extraOptions?: string; +} + +/** Describe every key as `field LABEL`, e.g. `account ASC, createTime DESC`. */ +function mongoIndexKeyDescription(keys: readonly { field: string; direction: string }[]): string { + return keys + .map((key) => { + const label = mongoIndexKeyLabel(key.direction); + return label ? `${key.field} ${label}` : key.field; + }) + .join(", "); +} + +/** Adapt a backend index spec into the management panel's row model. */ +export function toMongoIndexRow(source: MongoIndexSpecSource): MongoIndexRow { + return { + name: source.name, + keys: mongoIndexKeyDescription(source.keys ?? []), + isUnique: !!source.is_unique, + isProtected: isProtectedMongoIndex({ name: source.name, is_primary: source.is_primary }), + isSparse: !!source.is_sparse, + expireAfterSeconds: source.expire_after_seconds ?? undefined, + partialFilterExpression: source.partial_filter_expression?.trim() || undefined, + background: !!source.background, + bucketSize: source.bucket_size ?? undefined, + hidden: !!source.hidden, + // Absent means the caller did not say, and only the Legacy Agent path says false. + propertiesComplete: source.properties_complete !== false, + extraOptions: source.extra_options?.trim() || undefined, + }; +} + +/** + * Prefill the create-index form from an existing index row so the user can + * edit it as a drop + recreate. The keys description is parsed back into the + * visual field rows; options map one-to-one. + */ +export function mongoCreateIndexFormFromRow(row: MongoIndexRow): MongoCreateIndexForm { + const parsed = parseMongoIndexKeyDescription(row.keys); + const fields = parsed.length > 0 ? parsed.map((entry, index) => ({ id: index + 1, path: entry.field, type: entry.type })) : [{ id: 1, path: "", type: "1" as MongoIndexKeyType }]; + return { + name: row.name, + fields, + unique: row.isUnique, + sparse: row.isSparse, + expireAfterSeconds: row.expireAfterSeconds === undefined ? "" : String(row.expireAfterSeconds), + partialFilterExpression: row.partialFilterExpression ?? "", + background: row.background, + bucketSize: row.bucketSize === undefined ? "" : String(row.bucketSize), + hidden: row.hidden, + }; +} + +/** + * Build the review/preview text for an index edit (drop + recreate). + * Mirrors the two-step shell commands the backend runs, so production + * confirmation shows exactly what happens. + */ +export function mongoReplaceIndexPreview(database: string, collection: string, oldName: string, keysJson: string, optionsJson?: string): string { + const drop = mongoDropIndexPreview(database, collection, oldName); + const create = mongoCreateIndexPreview(database, collection, keysJson, optionsJson); + return `${drop}\n${create}`; +} diff --git a/crates/dbx-core/src/db/mongo_driver.rs b/crates/dbx-core/src/db/mongo_driver.rs index 842989ba4a..d63a8bf5ae 100644 --- a/crates/dbx-core/src/db/mongo_driver.rs +++ b/crates/dbx-core/src/db/mongo_driver.rs @@ -824,6 +824,188 @@ fn index_info_from_model(model: IndexModel) -> IndexInfo { } } +/// One key of a MongoDB index, with the direction/type kept as the server reports it +/// (`1`, `-1`, `text`, `2dsphere`, `hashed`, …). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct MongoIndexKey { + pub field: String, + pub direction: String, +} + +/// Full MongoDB index specification straight from `listIndexes`. +/// +/// The shared [`IndexInfo`] cannot carry `sparse`, `expireAfterSeconds`, `background` +/// or `bucketSize`, so index management reads this MongoDB-specific shape instead. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct MongoIndexSpec { + pub name: String, + pub keys: Vec, + pub is_unique: bool, + pub is_primary: bool, + pub is_sparse: bool, + /// TTL in seconds; `None` when the index does not expire. + pub expire_after_seconds: Option, + /// Partial index condition, serialized as JSON. + pub partial_filter_expression: Option, + /// Ignored by MongoDB 4.2+, still reported by older servers. + pub background: bool, + /// Only meaningful for geoHaystack indexes, removed in MongoDB 4.4+. + pub bucket_size: Option, + pub hidden: bool, + /// `false` when the properties above could not be read (Legacy Agent fallback), + /// so callers can avoid presenting defaults as if the server had reported them. + pub properties_complete: bool, + /// Options this build does not model, serialized as JSON for display only. + pub extra_options: Option, +} + +/// Index-spec fields this build maps explicitly; everything else lands in `extra_options`. +/// `v` and `ns` are server bookkeeping, listed here only to keep them out of that bucket. +const MODELED_INDEX_FIELDS: &[&str] = &[ + "name", + "key", + "v", + "ns", + "unique", + "sparse", + "expireAfterSeconds", + "partialFilterExpression", + "background", + "bucketSize", + "hidden", +]; + +/// Read every index of a collection with all of its options preserved. +pub async fn list_index_specs( + client: &Client, + database: &str, + collection: &str, +) -> Result, String> { + let database = validate_mongo_namespace_name(database, "Database")?; + let collection = validate_mongo_namespace_name(collection, "Collection")?; + // Raw command rather than `Collection::list_indexes`, whose IndexModel drops + // sparse/TTL/background. The driver cursor owns getMore and killCursors. + let mut cursor = client + .database(database) + .run_cursor_command(doc! { "listIndexes": collection }) + .await + .map_err(|e| e.to_string())?; + let mut specs = Vec::new(); + while let Some(document) = cursor.try_next().await.map_err(|e| e.to_string())? { + specs.push(index_spec_from_document(&document)); + } + Ok(specs) +} + +/// Canonicalize an index key direction so `1.0` and `1` read the same downstream. +fn index_key_direction(value: &Bson) -> String { + match value { + Bson::String(value) => value.clone(), + Bson::Int32(value) => value.to_string(), + Bson::Int64(value) => value.to_string(), + Bson::Double(value) if value.fract() == 0.0 && value.is_finite() => (*value as i64).to_string(), + value => value.to_string(), + } +} + +/// MongoDB accepts booleans and truthy numbers for index flags. +fn index_flag(document: &Document, field: &str) -> bool { + match document.get(field) { + Some(Bson::Boolean(value)) => *value, + Some(Bson::Int32(value)) => *value != 0, + Some(Bson::Int64(value)) => *value != 0, + Some(Bson::Double(value)) => *value != 0.0, + _ => false, + } +} + +/// TTL and bucket size arrive as any BSON number depending on server version. +fn index_number(document: &Document, field: &str) -> Option { + match document.get(field) { + Some(Bson::Int32(value)) => Some(i64::from(*value)), + Some(Bson::Int64(value)) => Some(*value), + Some(Bson::Double(value)) if value.is_finite() => Some(*value as i64), + _ => None, + } +} + +/// Map one `listIndexes` document onto [`MongoIndexSpec`]. +pub fn index_spec_from_document(document: &Document) -> MongoIndexSpec { + let keys = match document.get("key") { + Some(Bson::Document(keys)) => keys + .iter() + .map(|(field, value)| MongoIndexKey { field: field.clone(), direction: index_key_direction(value) }) + .collect::>(), + _ => Vec::new(), + }; + let name = + document.get_str("name").ok().map(str::to_string).filter(|name| !name.trim().is_empty()).unwrap_or_else(|| { + keys.iter().map(|key| format!("{}_{}", key.field, key.direction)).collect::>().join("_") + }); + let partial_filter_expression = match document.get("partialFilterExpression") { + Some(Bson::Document(filter)) => Some(bson_to_json(&Bson::Document(filter.clone())).to_string()), + _ => None, + }; + let extra: Document = document + .iter() + .filter(|(field, _)| !MODELED_INDEX_FIELDS.contains(&field.as_str())) + .map(|(field, value)| (field.clone(), value.clone())) + .collect(); + let extra_options = (!extra.is_empty()).then(|| bson_to_json(&Bson::Document(extra)).to_string()); + + let is_primary = name == "_id_"; + MongoIndexSpec { + is_primary, + name, + keys, + is_unique: index_flag(document, "unique") || is_primary, + is_sparse: index_flag(document, "sparse"), + expire_after_seconds: index_number(document, "expireAfterSeconds"), + partial_filter_expression, + background: index_flag(document, "background"), + bucket_size: index_number(document, "bucketSize"), + hidden: index_flag(document, "hidden"), + properties_complete: true, + extra_options, + } +} + +/// Degrade a shared [`IndexInfo`] into a spec for drivers that cannot report the +/// full option set. `properties_complete` stays `false` so nothing is presented as +/// server truth that was never read. +pub fn index_spec_from_index_info(info: &IndexInfo) -> MongoIndexSpec { + let keys = match info.index_type.as_deref().map(str::trim).filter(|spec| !spec.is_empty()) { + Some(spec) => spec + .split(',') + .map(str::trim) + .filter(|part| !part.is_empty()) + .map(|part| match part.rsplit_once(':') { + Some((field, direction)) if !field.trim().is_empty() => { + MongoIndexKey { field: field.trim().to_string(), direction: direction.trim().to_string() } + } + _ => MongoIndexKey { field: part.to_string(), direction: String::new() }, + }) + .collect::>(), + None => { + info.columns.iter().map(|field| MongoIndexKey { field: field.clone(), direction: String::new() }).collect() + } + }; + MongoIndexSpec { + name: info.name.clone(), + keys, + is_unique: info.is_unique, + is_primary: info.is_primary, + is_sparse: false, + expire_after_seconds: None, + partial_filter_expression: info.filter.clone(), + background: false, + bucket_size: None, + hidden: false, + properties_complete: false, + extra_options: None, + } +} + fn parse_find_collation(value: Option<&str>) -> Result, String> { let Some(raw) = value.map(str::trim).filter(|value| !value.is_empty()) else { return Ok(None); @@ -3229,6 +3411,155 @@ mod tests { assert!(index.is_primary); } + #[test] + fn index_spec_from_document_reports_every_modeled_property() { + let spec = index_spec_from_document(&doc! { + "v": 2, + "key": { "expiresAt": 1 }, + "name": "expires_ttl", + "unique": true, + "sparse": true, + "expireAfterSeconds": 3600, + "partialFilterExpression": { "archived": false }, + "hidden": true, + }); + + assert_eq!(spec.name, "expires_ttl"); + assert_eq!(spec.keys, vec![MongoIndexKey { field: "expiresAt".to_string(), direction: "1".to_string() }]); + assert!(spec.is_unique); + assert!(spec.is_sparse); + assert!(!spec.is_primary); + assert_eq!(spec.expire_after_seconds, Some(3600)); + assert_eq!(spec.partial_filter_expression.as_deref(), Some("{\"archived\":false}")); + assert!(spec.hidden); + assert!(spec.properties_complete); + assert_eq!(spec.extra_options, None); + } + + #[test] + fn index_spec_from_document_canonicalizes_whole_doubles_and_marks_the_default_index() { + let spec = index_spec_from_document(&doc! { + "key": { "_id": 1.0 }, + "name": "_id_", + }); + + assert!(spec.is_primary); + assert_eq!(spec.keys, vec![MongoIndexKey { field: "_id".to_string(), direction: "1".to_string() }]); + assert!(spec.is_unique, "the default _id index is unique even when the server omits the flag"); + } + + #[test] + fn index_spec_from_document_keeps_non_numeric_key_directions_literal() { + let spec = index_spec_from_document(&doc! { + "key": { "content": "text", "location": "2dsphere" }, + "name": "content_text_location_2dsphere", + }); + + assert_eq!( + spec.keys, + vec![ + MongoIndexKey { field: "content".to_string(), direction: "text".to_string() }, + MongoIndexKey { field: "location".to_string(), direction: "2dsphere".to_string() }, + ] + ); + } + + #[test] + fn index_spec_from_document_accepts_numeric_truthiness_for_flags() { + let spec = index_spec_from_document(&doc! { + "key": { "email": 1 }, + "name": "email_1", + "unique": 1, + "sparse": 0, + "background": 1, + }); + + assert!(spec.is_unique); + assert!(!spec.is_sparse); + assert!(spec.background); + } + + #[test] + fn index_spec_from_document_collects_unmodeled_options_without_losing_them() { + let spec = index_spec_from_document(&doc! { + "key": { "location": "2dsphere" }, + "name": "location_2dsphere", + "2dsphereIndexVersion": 3, + "collation": { "locale": "en" }, + }); + + let extra = spec.extra_options.expect("unmodeled options must be preserved"); + assert!(extra.contains("2dsphereIndexVersion"), "{extra}"); + assert!(extra.contains("collation"), "{extra}"); + assert!(!extra.contains("\"name\""), "modeled options must not be duplicated: {extra}"); + assert!(!extra.contains("\"key\""), "modeled options must not be duplicated: {extra}"); + } + + #[test] + fn index_spec_from_document_derives_a_name_when_the_server_omits_it() { + let spec = index_spec_from_document(&doc! { "key": { "email": 1, "createdAt": -1 } }); + + assert_eq!(spec.name, "email_1_createdAt_-1"); + } + + #[test] + fn index_spec_from_document_reads_int64_and_double_ttl_values() { + let from_int64 = + index_spec_from_document(&doc! { "key": { "a": 1 }, "name": "a_1", "expireAfterSeconds": Bson::Int64(90) }); + let from_double = index_spec_from_document( + &doc! { "key": { "a": 1 }, "name": "a_1", "expireAfterSeconds": Bson::Double(90.0) }, + ); + + assert_eq!(from_int64.expire_after_seconds, Some(90)); + assert_eq!(from_double.expire_after_seconds, Some(90)); + } + + #[test] + fn index_spec_from_index_info_marks_properties_as_incomplete() { + let spec = index_spec_from_index_info(&IndexInfo { + name: "email_1".to_string(), + columns: vec!["email".to_string()], + is_unique: true, + is_primary: false, + filter: Some("{\"archived\":false}".to_string()), + index_type: Some("email: 1".to_string()), + included_columns: None, + comment: None, + }); + + assert_eq!(spec.name, "email_1"); + assert_eq!(spec.keys, vec![MongoIndexKey { field: "email".to_string(), direction: "1".to_string() }]); + assert!(spec.is_unique); + assert_eq!(spec.partial_filter_expression.as_deref(), Some("{\"archived\":false}")); + // The Legacy Agent cannot report these, so the flag tells the UI not to + // present the false values as though they came from the server. + assert!(!spec.properties_complete); + assert!(!spec.is_sparse); + assert_eq!(spec.expire_after_seconds, None); + } + + #[test] + fn index_spec_from_index_info_falls_back_to_columns_without_an_index_type() { + let spec = index_spec_from_index_info(&IndexInfo { + name: "compound".to_string(), + columns: vec!["a".to_string(), "b".to_string()], + is_unique: false, + is_primary: false, + filter: None, + index_type: None, + included_columns: None, + comment: None, + }); + + assert_eq!( + spec.keys, + vec![ + MongoIndexKey { field: "a".to_string(), direction: String::new() }, + MongoIndexKey { field: "b".to_string(), direction: String::new() }, + ] + ); + } + #[test] fn mongo_namespace_validation_preserves_whitespace_and_rejects_only_empty_names() { assert_eq!(validate_mongo_namespace_name(" app ", "Database").unwrap(), " app "); diff --git a/crates/dbx-core/src/mongo_ops.rs b/crates/dbx-core/src/mongo_ops.rs index d9a65aeaa4..ae45fb8ec3 100644 --- a/crates/dbx-core/src/mongo_ops.rs +++ b/crates/dbx-core/src/mongo_ops.rs @@ -490,6 +490,43 @@ pub async fn mongo_distinct_core( } } +/// Read every index of a collection with its full MongoDB option set. +/// +/// The native driver reports `sparse`, `expireAfterSeconds`, `background` and +/// `bucketSize`, which the shared `IndexInfo` cannot carry. The Legacy Agent has no +/// equivalent method, so it degrades to the generic index listing with +/// `properties_complete: false` rather than presenting defaults as server truth. +pub async fn mongo_list_index_specs_core( + state: &AppState, + connection_id: &str, + database: &str, + collection: &str, +) -> Result, String> { + mongo_driver::validate_mongo_namespace_name(database, "Database")?; + mongo_driver::validate_mongo_namespace_name(collection, "Collection")?; + ensure_document_pool(state, connection_id).await?; + let is_native = { + let connections = state.connections.read().await; + match connections.get(connection_id).ok_or("Not found")? { + PoolKind::MongoDb(_) => true, + PoolKind::Agent(_) => false, + _ => return Err("Not a MongoDB connection".to_string()), + } + }; + + if !is_native { + // `list_indexes_core` owns the agent metadata session, so borrow nothing here. + let indexes = crate::schema::list_indexes_core(state, connection_id, database, database, collection).await?; + return Ok(indexes.iter().map(mongo_driver::index_spec_from_index_info).collect()); + } + + let connections = state.connections.read().await; + match connections.get(connection_id).ok_or("Not found")? { + PoolKind::MongoDb(client) => mongo_driver::list_index_specs(client, database, collection).await, + _ => Err("Not a MongoDB connection".to_string()), + } +} + pub async fn mongo_create_index_core( state: &AppState, connection_id: &str, diff --git a/crates/dbx-web/src/main.rs b/crates/dbx-web/src/main.rs index ed427eeacf..93c0ba3329 100644 --- a/crates/dbx-web/src/main.rs +++ b/crates/dbx-web/src/main.rs @@ -786,6 +786,7 @@ async fn main() { .route("/mongo/collection-stats", post(routes::mongo::collection_stats)) .route("/mongo/aggregate-documents", post(routes::mongo::aggregate_documents)) .route("/mongo/distinct", post(routes::mongo::distinct)) + .route("/mongo/list-index-specs", post(routes::mongo::list_index_specs)) .route("/mongo/create-index", post(routes::mongo::create_index)) .route("/mongo/create-user", post(routes::mongo::create_user)) .route("/mongo/run-command", post(routes::mongo::run_command)) diff --git a/crates/dbx-web/src/routes/mongo.rs b/crates/dbx-web/src/routes/mongo.rs index 0efc6d1152..6d0c2d3023 100644 --- a/crates/dbx-web/src/routes/mongo.rs +++ b/crates/dbx-web/src/routes/mongo.rs @@ -642,6 +642,23 @@ pub async fn distinct( Ok(Json(serde_json::to_value(result).map_err(|e| AppError::from(e.to_string()))?)) } +pub async fn list_index_specs( + State(state): State>, + headers: HeaderMap, + Json(req): Json, +) -> Result>, AppError> { + super::mcp_policy::ensure_scope(&state, &headers, &req.connection_id).await?; + let result = dbx_core::mongo_ops::mongo_list_index_specs_core( + &state.app, + &req.connection_id, + &req.database, + &req.collection, + ) + .await + .map_err(AppError::from)?; + Ok(Json(result)) +} + pub async fn create_index( State(state): State>, headers: HeaderMap, diff --git a/docs/mongo-index-management.md b/docs/mongo-index-management.md new file mode 100644 index 0000000000..f42a3becae --- /dev/null +++ b/docs/mongo-index-management.md @@ -0,0 +1,199 @@ +# MongoDB 索引管理 UI —— 未完成事项清单 + +> 本文档记录「仿 Navicat 的 MongoDB 集合索引管理面板」功能中**尚未完成/待验证**的工作。 +> 前端(UI、逻辑、测试)已完成并通过全部检查;**后端 Rust 代码已写完并在 `cargo check` / `cargo test` 中通过验证**(详见 §3)。 + +--- + +## 1. 当前进度总览 + +| 部分 | 状态 | +|------|------| +| 前端:集合右键 →「管理索引」菜单 | ✅ 完成 | +| 前端:`MongoIndexManagerDialog.vue` 面板(列表 + 属性区) | ✅ 完成 | +| 前端:索引创建表单(唯一键/稀疏/TTL/部分过滤器/背景/存储桶大小) | ✅ 完成 | +| 前端:i18n(en / zh-CN / zh-TW) | ✅ 完成 | +| 前端:单元测试(4 个测试文件) | ✅ 全部通过 | +| 前端:vue-tsc / oxlint / oxfmt | ✅ 全部通过 | +| 后端:Rust 源码编写(驱动 + ops + Tauri 命令 + Web 路由) | ✅ 已写完 | +| 后端:`cargo check` 编译验证 | ✅ **已通过**(见 §3 验证记录) | + +--- + +## 2. 后端 Rust 代码(已写完,待编译验证) + +以下文件均已完成修改,但**从未被 `cargo` 编译过**,不排除存在语法 / 类型错误,需要在一台装有 Visual Studio Build Tools(含 `kernel32.lib`)的机器上验证。 + +| 文件 | 改动内容 | +|------|----------| +| `crates/dbx-core/src/db/mongo_driver.rs` | 新增 `MongoIndexKey`、`MongoIndexSpec` 结构体;`list_index_specs()`(原生驱动,raw `listIndexes` + 游标读取);`index_spec_from_document()`、`index_spec_from_index_info()`;辅助函数 `index_key_direction` / `index_flag` / `index_number`;常量 `MODELED_INDEX_FIELDS`;+9 个单元测试 | +| `crates/dbx-core/src/mongo_ops.rs` | 新增 `mongo_list_index_specs_core()`(原生驱动走 specs;Legacy Agent 走 `crate::schema::list_indexes_core` 降级并在 `properties_complete: false` 中标记) | +| `src-tauri/src/commands/mongo_cmd.rs` | 新增只读 Tauri 命令 `mongo_list_index_specs` | +| `src-tauri/src/lib.rs` | 注册 `commands::mongo_cmd::mongo_list_index_specs`(~1806 行) | +| `crates/dbx-web/src/routes/mongo.rs` | 新增只读路由 handler `list_index_specs`(POST,`ensure_scope` 读策略,返回 `Vec`) | +| `crates/dbx-web/src/main.rs` | 注册路由 `POST /api/mongo/list-index-specs` | + +### 2.1 编译验证结论(2026-08-13 已完成) + +经人工代码复核 + `cargo check` 编译验证,§3 中列出的 4 项人工复核点全部通过: + +- ✅ `mongo_driver.rs::list_index_specs` 中 `client.database(database).run_cursor_command(doc! { "listIndexes": collection })` 的 API 用法与同文件的 `aggregate_documents`(约 1186 行 `db.run_cursor_command(command)`)完全一致 —— 同样的 `Database::run_cursor_command(Document)` 签名 + `cursor.try_next()` 流式读取(依赖 `futures::TryStreamExt`,第 14 行已 import)。 +- ✅ `mongo_ops.rs` 中 `crate::schema::list_indexes_core(state, connection_id, database, database, collection)` 的调用签名与 `schema.rs:5515` 的定义 `list_indexes_core(state: &AppState, connection_id: &str, database: &str, schema: &str, table: &str)` 一致 —— `(state, connection_id, database, database [作 schema], collection [作 table])`,对应 Mongo 的库即 schema 语义。Legacy Agent 降级时正确写出 `properties_complete: false`。 +- ✅ `src-tauri/src/lib.rs` 中 `commands::mongo_cmd::mongo_list_index_specs` 注册在 1808 行 —— 与同模块的 `mongo_create_index`(1809)/`mongo_drop_indexes`(1810)相邻,且均位于 `tauri::generate_handler![...]` 同一个宏调用内,作用域正确。 +- ✅ `crates/dbx-web/src/main.rs:620` 注册的 `.route("/mongo/list-index-specs", post(routes::mongo::list_index_specs))` —— `list_index_specs` 是独立路径,不与 `create-index`/`drop-indexes` 等任何已有路由冲突;`routes::mongo::list_index_specs` 返回 `Vec`,handler 不带写策略守卫(只读,符合预期)。 + +### 2.2 历史:本机曾被报告无法编译的根因 + +```text +rust-lld: error: could not open 'kernel32.lib': no such file or directory + could not open 'kernel32.lib' / 'ntdll.lib' / 'userenv.lib' / 'ws2_32.lib' / 'dbghelp.lib' +``` + +- 已确认三处均无 VS / Windows SDK: + - `C:\Program Files (x86)\Windows Kits\10\Lib` ❌ + - `C:\Program Files\Microsoft Visual Studio` ❌(含 `C:\BuildTools`) + - `C:\mingw64` / `C:\msys64` / Git 自带 gcc ❌(无 gcc 也无 MinGW) +- 工具链只有 `stable-x86_64-pc-windows-msvc`(无 GNU target)。 +- **复查结论(2026-08-13)**:上述「缺少 SDK」的诊断不成立 —— Windows 10 SDK 实际已安装在 `C:/Program Files (x86)/Windows Kits/10/Lib/{10.0.26100.0, 10.0.28000.0}`,MSVC 链接器也在 PATH(`/d/dev/ms/soft/VC/Tools/MSVC/14.51.36231/bin/Hostx64/x64`)。`cargo` 此前未找到,仅因 `~/.cargo/bin` 不在 PATH 中;加入后 `cargo check` / `cargo test` 均可运行。 +- 解决方式(任选其一,需网络/管理员权限): + 1. 安装 **Visual Studio Build Tools**(勾选「使用 C++ 的桌面开发」+ Windows 10/11 SDK); + 2. 或在**有 SDK 的机器 / CI** 上跑 `cargo test -p dbx-core --lib mongo_driver::`。 + +--- + +## 3. 验证清单(Rust 侧,已逐条执行 ✅) + +```bash +# 1. 编译 dbx-core +cargo check -p dbx-core --lib + +# 2. 跑新增的驱动单测(9 个) +cargo test -p dbx-core --lib mongo_driver::index_spec_ + +# 3. 编译 Tauri 命令层 +cargo check --manifest-path src-tauri/Cargo.toml + +# 4. 编译 Web 路由层 +cargo check -p dbx-web +``` + +需要重点人工复核的点: + +- [x] `crates/dbx-core/src/db/mongo_driver.rs` 中 `Client::database().run_cursor_command()` API 用法是否正确(`aggregate_documents` 里已有该用法作为参照,约 1000 行); +- [x] `mongo_ops.rs` 里对 `crate::schema::list_indexes_core` 的调用签名(`(state, connection_id, database, schema, table)`)是否与 `schema.rs:5515` 一致(现在传的是 `database, database, collection`); +- [x] `src-tauri/src/lib.rs` 命令注册列表中新增项是否在 `generate_handler!` 宏(或多个 `invoke_handler`)的**正确作用域**内; +- [x] `crates/dbx-web/src/main.rs` 路由注册处 `routes::mongo::list_index_specs` 是否有歧义冲突。 + +### 3.1 实际验证记录(2026-08-13,本机) + +环境:`cargo 1.97.1` / `stable-x86_64-pc-windows-msvc`,MSVC 14.51 + Windows 10 SDK 10.0.26100.0 / 10.0.28000.0。 + +```text +# 步骤 1:编译 dbx-core +$ cargo check -p dbx-core --lib --no-default-features \ + --features "duckdb-sidecar,mq-admin,system-fonts" + Finished `dev` profile in 1m 08s +``` + +> 注:本次 `--no-default-features` 是为绕开 `sqlite-sqlcipher`(其 `libsqlite3-sys` 触发 `openssl-sys` 源码编译,而本机 `perl` 是 MSYS2 版、缺 `Locale/Maketext/Simple.pm`,导致 OpenSSL Configure 失败)。 +> **这不是本次索引功能改动引入的问题**:`sqlcipher` 默认 feature 一直依赖 vendored OpenSSL,与 mongo_index_specs 的任何代码无关。 +> 在装有 Strawberry Perl / 完整 MSYS2 或预编译 OpenSSL 的 CI 上跑默认 features 即可。 + +```text +# 步骤 2:编译 dbx-web(同上 --no-default-features 跳过 sqlcipher) +$ cargo check -p dbx-web --no-default-features + Finished `dev` profile in 29.16s + +# 步骤 3:编译 Tauri 命令层 +$ (cd src-tauri && cargo check --no-default-features \ + --features "duckdb-sidecar,mq-admin,system-fonts") + Finished `dev` profile in 9m 50s + +# 步骤 4:跑 mongo_driver 单测(含 9 个新增 index_spec_ 用例) +$ cargo test -p dbx-core --lib --no-default-features \ + --features "duckdb-sidecar,mq-admin,system-fonts" mongo_driver:: + test result: ok. 97 passed; 0 failed; 0 ignored +``` + +新增的 9 个 `index_spec_*` 测试全部 PASS: +`index_spec_from_document_reports_every_modeled_property`、 +`index_spec_from_document_canonicalizes_whole_doubles_and_marks_the_default_index`、 +`index_spec_from_document_keeps_non_numeric_key_directions_literal`、 +`index_spec_from_document_accepts_numeric_truthiness_for_flags`、 +`index_spec_from_document_collects_unmodeled_options_without_losing_them`、 +`index_spec_from_document_derives_a_name_when_the_server_omits_it`、 +`index_spec_from_document_reads_int64_and_double_ttl_values`、 +`index_spec_from_index_info_marks_properties_as_incomplete`、 +`index_spec_from_index_info_falls_back_to_columns_without_an_index_type`。 + +编译期仅遗留两条**与本次改动无关的预存 warning**(已通过 `git stash` 在 HEAD 上单独复现,确认非本次引入): +- `crates/dbx-core/src/db/agent_driver.rs:3093` `unused import: spawn_agent_process` +- `crates/dbx-core/src/mongo_ops.rs:677` `unused import: super::*`(仅 `#[cfg(test)]` 模块,`#[cfg(unix)]` 用例在 Windows 上不编译所致) + +--- + +## 4. 前端已切换到新端点,但要记得两件事 + +1. **api 层新增项**: + - `apps/desktop/src/lib/backend/tauri.ts` → `mongoListIndexSpecs`(调 `invoke("mongo_list_index_specs", ...)`) + - `apps/desktop/src/lib/backend/http.ts` → `mongoListIndexSpecs`(`POST /api/mongo/list-index-specs`) + - `apps/desktop/src/lib/backend/api.ts` → `export const mongoListIndexSpecs = forward("mongoListIndexSpecs")` + - 类型 `MongoIndexSpec` / `MongoIndexKey` 已定义在 `tauri.ts` 并被 `http.ts` import。 + +2. ⚠️ **旧版(Legacy Agent)连接** 走降级路径:`properties_complete: false`,面板里稀疏/TTL/背景/存储桶会隐藏,只显示「使用原生驱动连接以查看…」提示 —— 这是有意为之(避免把后端读不到的值当作服务器真实值展示)。 + +--- + +## 5. 回归测试(前端,已经全绿,作为基准) + +```bash +node node_modules/vitest/vitest.mjs run \ + apps/desktop/src/composables/__tests__/useSidebarDatabaseSpecificMutationRuntime.mongo.spec.ts \ + apps/desktop/src/lib/sidebar/__tests__/mongoCollectionMutation.spec.ts \ + packages/app-tests/productionGuardEntrypoints.test.ts \ + apps/desktop/src/components/sidebar/__tests__/SidebarTreeItemDialogs.mongoIndex.spec.ts + +node node_modules/vue-tsc/bin/vue-tsc.js --noEmit --project apps/desktop/tsconfig.json +``` + +- 全量 `vitest run`:**6996 / 6997 通过**,唯一失败 `windowsInstallerTemplate.spec.ts` 为**预存在问题**(与本次改动无关,已在`git stash`后单独复现)。 +- 额外注意:`packages/app-tests` 里没有 `mongoListIndexSpecs` 的 guard 测试(前端新增的 API forward 不受现有守卫约束影响)。 + +--- + +## 6. 已知取舍 / 后续可做 + +| 项 | 说明 | +|----|------| +| `background` / `bucketSize` | MongoDB 4.2+ 忽略 background、4.4+ 移除 geoHaystack 后 bucketSize 失效 —— 面板已标注「兼容选项」 | +| `hidden` 索引 | 后端已透传并在面板显示,但新建表单**未提供** hidden 开关(`createIndexes` 支持 `hidden`,可后续加) | +| 字段 datalist 补全 | 依赖 `listMongoCompletionFields` 采样;MongoDB 无 schema,空集合无建议 | +| 部分过滤器校验 | 前端只做 JSON 合法性校验,结构合理性交给服务器 | + +--- + +## 7. 文件清单(本次全部改动) + +``` +apps/desktop/src/components/sidebar/MongoIndexManagerDialog.vue (新增) +apps/desktop/src/components/sidebar/SidebarTreeItemDialogs.vue +apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue +apps/desktop/src/components/sidebar/sidebarAsyncDialogs.ts +apps/desktop/src/components/sidebar/sidebarTreeDialogState.ts +apps/desktop/src/composables/useSidebarDatabaseSpecificMutationRuntime.ts +apps/desktop/src/lib/sidebar/mongoCollectionMutation.ts +apps/desktop/src/lib/backend/api.ts +apps/desktop/src/lib/backend/http.ts +apps/desktop/src/lib/backend/tauri.ts +apps/desktop/src/i18n/locales/en.ts +apps/desktop/src/i18n/locales/zh-CN.ts +apps/desktop/src/i18n/locales/zh-TW.ts +apps/desktop/src/composables/__tests__/useSidebarDatabaseSpecificMutationRuntime.mongo.spec.ts +apps/desktop/src/lib/sidebar/__tests__/mongoCollectionMutation.spec.ts +crates/dbx-core/src/db/mongo_driver.rs (Rust,✅ 已编译/测试通过) +crates/dbx-core/src/mongo_ops.rs (Rust,✅ 已编译通过) +src-tauri/src/commands/mongo_cmd.rs (Rust,✅ 已编译通过) +src-tauri/src/lib.rs (Rust,✅ 已编译通过) +crates/dbx-web/src/routes/mongo.rs (Rust,✅ 已编译通过) +crates/dbx-web/src/main.rs (Rust,✅ 已编译通过) +``` \ No newline at end of file diff --git a/src-tauri/src/commands/mongo_cmd.rs b/src-tauri/src/commands/mongo_cmd.rs index d46616f8fa..db74d55d32 100644 --- a/src-tauri/src/commands/mongo_cmd.rs +++ b/src-tauri/src/commands/mongo_cmd.rs @@ -321,6 +321,17 @@ pub async fn mongo_distinct( .await } +/// Read-only listing of a collection's indexes with their full MongoDB options. +#[tauri::command] +pub async fn mongo_list_index_specs( + state: State<'_, Arc>, + connection_id: String, + database: String, + collection: String, +) -> Result, String> { + dbx_core::mongo_ops::mongo_list_index_specs_core(&state, &connection_id, &database, &collection).await +} + #[tauri::command] pub async fn mongo_create_index( state: State<'_, Arc>, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 91cabae4d2..352865f829 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2000,6 +2000,7 @@ pub fn run() { commands::mongo_cmd::mongo_collection_stats, commands::mongo_cmd::mongo_aggregate_documents, commands::mongo_cmd::mongo_distinct, + commands::mongo_cmd::mongo_list_index_specs, commands::mongo_cmd::mongo_create_index, commands::mongo_cmd::mongo_create_user, commands::mongo_cmd::mongo_run_command,