From a78fc48a7b4f53761a9fec21348bc2e7357ddb33 Mon Sep 17 00:00:00 2001 From: Loc Nguyen <113083148+thailoc-dev@users.noreply.github.com> Date: Fri, 18 Sep 2026 00:45:37 +0700 Subject: [PATCH] feat(mongodb): support find explain in the SQL editor --- .../lib/__tests__/sql/sqlRisk.mongodb.spec.ts | 10 ++++ .../__tests__/mongoExplainFind.spec.ts | 42 ++++++++++++++++ apps/desktop/src/lib/backend/api.ts | 1 + apps/desktop/src/lib/backend/http.ts | 16 ++++++ apps/desktop/src/lib/backend/tauri.ts | 16 ++++++ .../__tests__/readOnlyWriteAccess.spec.ts | 21 ++++++++ apps/desktop/src/lib/mongo/mongoCompletion.ts | 1 + .../src/lib/mongo/mongoShellCommand.ts | 50 +++++++++++++++++-- apps/desktop/src/lib/sql/sqlRisk.ts | 2 +- .../queryStore.mongoExecutionSummary.spec.ts | 35 +++++++++++++ apps/desktop/src/stores/queryStore.ts | 22 ++++++++ packages/app-tests/mongoCompletion.test.ts | 6 +-- packages/app-tests/mongoShellCommand.test.ts | 35 ++++++++++++- src-tauri/src/commands/mongo_cmd.rs | 41 +++++++++++++++ src-tauri/src/lib.rs | 1 + 15 files changed, 290 insertions(+), 9 deletions(-) create mode 100644 apps/desktop/src/lib/backend/__tests__/mongoExplainFind.spec.ts diff --git a/apps/desktop/src/lib/__tests__/sql/sqlRisk.mongodb.spec.ts b/apps/desktop/src/lib/__tests__/sql/sqlRisk.mongodb.spec.ts index 7be9e26080..73cadca9e0 100644 --- a/apps/desktop/src/lib/__tests__/sql/sqlRisk.mongodb.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/sqlRisk.mongodb.spec.ts @@ -24,6 +24,16 @@ describe("MongoDB shell statement risk", () => { } }); + it.each(["", '"queryPlanner"', '"executionStats"', '"allPlansExecution"'])("treats find explain(%s) as read-only without hiding batch writes", (verbosity) => { + const explain = `db.demo.find({}).explain(${verbosity})`; + expect(classifySqlRisk(explain, { dialect: "mongodb" }).risk).toBe("read"); + for (const sql of [`${explain}; db.demo.insertOne({active: true})`, `db.demo.insertOne({active: true}); ${explain}`]) { + const assessment = classifySqlRisk(sql, { dialect: "mongodb" }); + expect(assessment.risk).toBe("write"); + expect(assessment.statements.map((statement) => statement.risk)).toEqual(sql.startsWith(explain) ? ["read", "write"] : ["write", "read"]); + } + }); + it("retains per-command assessments for shell batches", () => { const assessment = classifySqlRisk('db.getCollection("demo").find({}); db.getCollection("demo").insertOne({"active": true})', { dialect: "mongodb" }); expect(assessment.risk).toBe("write"); diff --git a/apps/desktop/src/lib/backend/__tests__/mongoExplainFind.spec.ts b/apps/desktop/src/lib/backend/__tests__/mongoExplainFind.spec.ts new file mode 100644 index 0000000000..34669ee94a --- /dev/null +++ b/apps/desktop/src/lib/backend/__tests__/mongoExplainFind.spec.ts @@ -0,0 +1,42 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ invoke: vi.fn() })); + +vi.mock("@tauri-apps/api/core", () => ({ invoke: mocks.invoke })); +vi.mock("@tauri-apps/api/event", () => ({ listen: vi.fn() })); + +const options = { skip: 2, limit: 10, filter: '{"active":true}', projection: '{"name":1}', sort: '{"name":1}', collation: '{"locale":"en"}', verbosity: "executionStats" }; +const plan = { queryPlanner: { namespace: "app.users" } }; + +describe("MongoDB find explain adapters", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it.each(["explain-1", undefined])("forwards optional executionId %s through HTTP", async (executionId) => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue(plan) }); + vi.stubGlobal("fetch", fetchMock); + const { mongoExplainFind } = await import("@/lib/backend/http"); + + const result = executionId === undefined ? await mongoExplainFind("mongo-1", "app", "users", options) : await mongoExplainFind("mongo-1", "app", "users", options, executionId); + + expect(result).toEqual(plan); + const [url, init] = fetchMock.mock.calls.at(-1) as [string, RequestInit]; + expect(url).toBe("/api/mongo/explain-find"); + expect(JSON.parse(String(init.body))).toEqual({ connectionId: "mongo-1", database: "app", collection: "users", ...options, ...(executionId === undefined ? {} : { executionId }) }); + }); + + it.each(["explain-1", undefined])("forwards optional executionId %s through Tauri", async (executionId) => { + mocks.invoke.mockResolvedValue(plan); + const { mongoExplainFind } = await import("@/lib/backend/tauri"); + + const result = executionId === undefined ? await mongoExplainFind("mongo-1", "app", "users", options) : await mongoExplainFind("mongo-1", "app", "users", options, executionId); + + expect(result).toEqual(plan); + expect(mocks.invoke).toHaveBeenCalledWith("mongo_explain_find", { connectionId: "mongo-1", database: "app", collection: "users", ...options, executionId }); + }); +}); diff --git a/apps/desktop/src/lib/backend/api.ts b/apps/desktop/src/lib/backend/api.ts index c03ff91ad6..0bfaae9ebb 100644 --- a/apps/desktop/src/lib/backend/api.ts +++ b/apps/desktop/src/lib/backend/api.ts @@ -825,6 +825,7 @@ export const mongoUpdateDocument = forward("mongoUpdateDocument"); export const mongoUpdateDocuments = forward("mongoUpdateDocuments"); export const mongoReplaceDocument = forward("mongoReplaceDocument"); export const mongoBulkWrite = forward("mongoBulkWrite"); +export const mongoExplainFind = forward("mongoExplainFind"); export const documentDeleteDocument = forward("documentDeleteDocument"); export const documentSaveMeilisearchBatch = forward("documentSaveMeilisearchBatch"); export const meilisearchSearchDocuments = forward("meilisearchSearchDocuments"); diff --git a/apps/desktop/src/lib/backend/http.ts b/apps/desktop/src/lib/backend/http.ts index c5408ad5d8..b5b09ba7f7 100644 --- a/apps/desktop/src/lib/backend/http.ts +++ b/apps/desktop/src/lib/backend/http.ts @@ -4676,6 +4676,22 @@ export async function documentUpdateDocument(connectionId: string, database: str }); } +export async function mongoExplainFind(connectionId: string, database: string, collection: string, options: { skip: number; limit: number; filter?: string; projection?: string; sort?: string; collation?: string; verbosity?: string }, executionId?: string): Promise { + return post("/api/mongo/explain-find", { + connectionId, + database, + collection, + skip: options.skip, + limit: options.limit, + filter: options.filter, + projection: options.projection, + sort: options.sort, + collation: options.collation, + verbosity: options.verbosity, + executionId, + }); +} + export async function mongoBulkWrite(connectionId: string, database: string, collection: string, operationsJson: string, optionsJson?: string): Promise { return post("/api/mongo/bulk-write", { connectionId, diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts index 291d98564f..8752972e2b 100644 --- a/apps/desktop/src/lib/backend/tauri.ts +++ b/apps/desktop/src/lib/backend/tauri.ts @@ -4446,6 +4446,22 @@ export async function documentUpdateDocument(connectionId: string, database: str }); } +export async function mongoExplainFind(connectionId: string, database: string, collection: string, options: { skip: number; limit: number; filter?: string; projection?: string; sort?: string; collation?: string; verbosity?: string }, executionId?: string): Promise { + return invoke("mongo_explain_find", { + connectionId, + database, + collection, + skip: options.skip, + limit: options.limit, + filter: options.filter, + projection: options.projection, + sort: options.sort, + collation: options.collation, + verbosity: options.verbosity, + executionId, + }); +} + export async function mongoBulkWrite(connectionId: string, database: string, collection: string, operationsJson: string, optionsJson?: string): Promise { return invoke("mongo_bulk_write", { connectionId, diff --git a/apps/desktop/src/lib/database/__tests__/readOnlyWriteAccess.spec.ts b/apps/desktop/src/lib/database/__tests__/readOnlyWriteAccess.spec.ts index 45c4291fcf..a73d03db5b 100644 --- a/apps/desktop/src/lib/database/__tests__/readOnlyWriteAccess.spec.ts +++ b/apps/desktop/src/lib/database/__tests__/readOnlyWriteAccess.spec.ts @@ -117,6 +117,27 @@ describe("read-only write unlock", () => { expect(connectionIsEffectivelyReadOnly(connection)).toBe(false); }); + it.each(["", '"queryPlanner"', '"executionStats"', '"allPlansExecution"'])("does not request write unlock for MongoDB find explain(%s)", async (verbosity) => { + const store = useReadOnlyUnlockStore(); + const connection = { id: "mongo", read_only: true, db_type: "mongodb" as const }; + const pending = ensureReadOnlyWriteAccess({ connection, sql: `db.demo.find({}).explain(${verbosity})` }); + + expect(connectionWriteUnlockState).not.toHaveBeenCalled(); + expect(store.pending).toBeUndefined(); + await expect(pending).resolves.toBe(true); + expect(unlockConnectionWrites).not.toHaveBeenCalled(); + }); + + it("still requests write unlock for MongoDB explain mixed with writes", async () => { + const connection = { id: "mongo", read_only: true, db_type: "mongodb" as const }; + const pending = ensureReadOnlyWriteAccess({ connection, sql: "db.demo.find({}).explain(); db.demo.insertOne({active: true})" }); + const store = await waitForPending("mongo"); + + store.cancel(); + await expect(pending).resolves.toBe(false); + expect(unlockConnectionWrites).not.toHaveBeenCalled(); + }); + it("returns to effectively read-only when the local timer expires", async () => { vi.useFakeTimers(); const store = useReadOnlyUnlockStore(); diff --git a/apps/desktop/src/lib/mongo/mongoCompletion.ts b/apps/desktop/src/lib/mongo/mongoCompletion.ts index 335e880074..962c96c219 100644 --- a/apps/desktop/src/lib/mongo/mongoCompletion.ts +++ b/apps/desktop/src/lib/mongo/mongoCompletion.ts @@ -153,6 +153,7 @@ const CURSOR_METHODS = [ { label: "sort", detail: "Sort cursor results", apply: "sort({ ${field}: 1 })" }, { label: "limit", detail: "Limit cursor results", apply: "limit(100)" }, { label: "skip", detail: "Skip cursor results", apply: "skip(0)" }, + { label: "explain", detail: "Show the query plan instead of the results", apply: 'explain("executionStats")' }, ] as const; /** diff --git a/apps/desktop/src/lib/mongo/mongoShellCommand.ts b/apps/desktop/src/lib/mongo/mongoShellCommand.ts index 7d94a9795b..526ca9abd5 100644 --- a/apps/desktop/src/lib/mongo/mongoShellCommand.ts +++ b/apps/desktop/src/lib/mongo/mongoShellCommand.ts @@ -219,6 +219,13 @@ export interface MongoFindCommand { collation?: string; } +export interface MongoFindExplainCommand extends MongoFindCommand { + verbosity: MongoExplainVerbosity; +} + +export type MongoExplainVerbosity = "queryPlanner" | "executionStats" | "allPlansExecution"; +const EXPLAIN_VERBOSITIES: readonly MongoExplainVerbosity[] = ["queryPlanner", "executionStats", "allPlansExecution"]; + export interface MongoFindPaginationPlan { pageOffset: number; pageLimit: number; @@ -284,6 +291,7 @@ type MongoWriteKind = "runCommand" | "insert" | "update" | "replace" | "bulkWrit export type MongoCommand = | ({ kind: "find" } & MongoFindCommand) + | ({ kind: "findExplain" } & MongoFindExplainCommand) | ({ kind: "findOne" } & MongoFindOneCommand) | MongoVersionCommand | MongoShowDatabasesCommand @@ -594,6 +602,35 @@ function parseCollectionCountCommand(source: string, method: "countDocuments" | }; } +/** + * `find(...)…explain([verbosity])`: the query plan for a find, with explain as the + * final chain call. Everything before it is parsed exactly as the find would be. + */ +export function parseMongoFindExplainCommand(input: string): MongoFindExplainCommand | null { + const source = input.trim().replace(/;$/, "").trim(); + const target = parseFindTarget(source); + if (!target) return null; + const findOpenIndex = source.indexOf("(", target.findCallIndex); + const findCloseIndex = findMatchingParen(source, findOpenIndex); + if (findCloseIndex < 0) return null; + + const chain = source.slice(findCloseIndex + 1).trim(); + const calls = listChainedCalls(chain); + const last = calls?.[calls.length - 1]; + if (!calls || last?.name !== "explain") return null; + + const verbosityArg = last.args.trim(); + let verbosity: MongoExplainVerbosity = "queryPlanner"; + if (verbosityArg) { + const literal = /^(["'])([^"']*)\1$/.exec(verbosityArg)?.[2]; + if (!literal || !EXPLAIN_VERBOSITIES.includes(literal as MongoExplainVerbosity)) return null; + verbosity = literal as MongoExplainVerbosity; + } + + const find = parseMongoFindCommand(source.slice(0, findCloseIndex + 1) + chain.slice(0, last.index)); + return find ? { ...find, verbosity } : null; +} + function parseFindCountCommand(source: string): MongoCountDocumentsCommand | null { const target = parseFindTarget(source); if (!target) return null; @@ -888,6 +925,10 @@ export function parseMongoCommand(input: string): ParsedMongoCommand | null { const count = parseMongoCountDocumentsCommand(source); return count ? { kind: "countDocuments", ...count } : null; }, + (source) => { + const explain = parseMongoFindExplainCommand(source); + return explain ? { kind: "findExplain", ...explain } : null; + }, (source) => { const find = parseMongoFindCommand(source); return find ? { kind: "find", ...find } : null; @@ -1606,10 +1647,13 @@ function describeUnsupportedFindChain(chain: string): string | null { if (!calls) return null; const offending = calls.find((call) => !FIND_CHAIN_METHODS.has(call.name) || (isNoopCursorMethod(call.name) && call.args.trim())); if (!offending) return null; - const supported = "Supported after find(): sort, skip, limit, collation, count, toArray, pretty."; + const supported = "Supported after find(): sort, skip, limit, collation, count, explain, toArray, pretty."; switch (offending.name) { - case "explain": - return `find().explain() is not supported in the editor yet; aggregate([...], { explain: true }) is. ${supported}`; + case "explain": { + const verbosity = offending.args.trim(); + if (calls[calls.length - 1] !== offending) return "find().explain() must be the final chain method."; + return `find().explain() verbosity must be "queryPlanner", "executionStats" or "allPlansExecution"${verbosity ? `, got ${verbosity}` : ""}.`; + } case "itcount": case "size": return `find().${offending.name}() is not supported; use find().count() or countDocuments() to count results. ${supported}`; diff --git a/apps/desktop/src/lib/sql/sqlRisk.ts b/apps/desktop/src/lib/sql/sqlRisk.ts index 0055cb4261..20d8f74355 100644 --- a/apps/desktop/src/lib/sql/sqlRisk.ts +++ b/apps/desktop/src/lib/sql/sqlRisk.ts @@ -92,7 +92,7 @@ function searchEngineAssessment(sql: string, dialect: DatabaseType | string | un return { risk: risk === "read" ? "read" : risk === "write" ? "write" : "ddl", firstKeyword: "rest" }; } -const MONGO_READ_KINDS = new Set(["find", "findOne", "countDocuments", "distinct", "getIndexes", "collectionStats", "version", "showDatabases", "use"]); +const MONGO_READ_KINDS = new Set(["find", "findExplain", "findOne", "countDocuments", "distinct", "getIndexes", "collectionStats", "version", "showDatabases", "use"]); const MONGO_DDL_KINDS = new Set(["createIndex", "dropIndex", "dropIndexes", "dropCollection", "createUser"]); /** diff --git a/apps/desktop/src/stores/__tests__/queryStore.mongoExecutionSummary.spec.ts b/apps/desktop/src/stores/__tests__/queryStore.mongoExecutionSummary.spec.ts index 0097987f42..81069aef17 100644 --- a/apps/desktop/src/stores/__tests__/queryStore.mongoExecutionSummary.spec.ts +++ b/apps/desktop/src/stores/__tests__/queryStore.mongoExecutionSummary.spec.ts @@ -6,6 +6,8 @@ const mocks = vi.hoisted(() => ({ ensureConnected: vi.fn(), getConnectionConfig: vi.fn(), mongoFindDocuments: vi.fn(), + mongoExplainFind: vi.fn(), + cancelQuery: vi.fn(), mongoParseShellCommand: vi.fn(), })); @@ -14,6 +16,8 @@ vi.mock("@/lib/backend/api", async (importOriginal) => { return { ...actual, mongoFindDocuments: mocks.mongoFindDocuments, + mongoExplainFind: mocks.mongoExplainFind, + cancelQuery: mocks.cancelQuery, mongoParseShellCommand: mocks.mongoParseShellCommand, }; }); @@ -118,4 +122,35 @@ describe("queryStore MongoDB execution summary", () => { ], }); }); + + it("forwards the active execution ID to a pending explain and its cancellation", async () => { + let rejectExplain!: (reason: Error) => void; + mocks.mongoExplainFind.mockImplementation( + () => + new Promise((_resolve, reject) => { + rejectExplain = reject; + }), + ); + mocks.cancelQuery.mockImplementation(async () => { + rejectExplain(new Error("Query canceled")); + return true; + }); + const { useQueryStore } = await import("@/stores/queryStore"); + const store = useQueryStore(); + const tabId = store.createTab("mongo-1", "app", "Query"); + const execution = store.executeTabSql(tabId, 'db.users.find({}).explain("executionStats")'); + + await vi.waitFor(() => expect(mocks.mongoExplainFind).toHaveBeenCalledOnce()); + const executionId = store.tabs.find((tab) => tab.id === tabId)!.executionId; + try { + expect(executionId).toEqual(expect.any(String)); + expect(mocks.mongoExplainFind).toHaveBeenCalledWith("mongo-1", "app", "users", expect.objectContaining({ verbosity: "executionStats" }), executionId); + await expect(store.cancelTabExecution(tabId)).resolves.toBe(true); + expect(mocks.cancelQuery).toHaveBeenCalledWith(executionId); + } finally { + rejectExplain(new Error("Query canceled")); + await execution; + } + expect(store.tabs.find((tab) => tab.id === tabId)).toMatchObject({ isExecuting: false, executionId: undefined }); + }); }); diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index 5a64b54fd0..4689c0b59f 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -6595,6 +6595,28 @@ export const useQueryStore = defineStore("query", () => { }); break; } + case "findExplain": { + queryExecutionLog("info", "mongo-explain:start", { traceId, collection: mongoCommand.collection, database: currentDatabase }); + const plan = await api.mongoExplainFind( + executionConnectionId, + currentDatabase, + mongoCommand.collection, + { + skip: mongoCommand.skip, + limit: mongoCommand.limit, + filter: mongoCommand.filter, + projection: mongoCommand.projection, + sort: mongoCommand.sort, + collation: mongoCommand.collation, + verbosity: mongoCommand.verbosity, + }, + executionId, + ); + allResults.push(markQueryResultRowsRaw(annotateMongoResult(mongoDocumentsToQueryResult([plan], performance.now() - commandStartedAt, 1)))); + mongoEditTarget = undefined; + queryExecutionLog("info", "mongo-explain:done", { traceId, collection: mongoCommand.collection, database: currentDatabase, elapsed: elapsed() }); + break; + } case "aggregate": { if (options?.mongoSafety) { const safety = evaluateMongoAggregateSafety(mongoCommand, options.mongoSafety); diff --git a/packages/app-tests/mongoCompletion.test.ts b/packages/app-tests/mongoCompletion.test.ts index 0e54d7bb64..54ba719309 100644 --- a/packages/app-tests/mongoCompletion.test.ts +++ b/packages/app-tests/mongoCompletion.test.ts @@ -137,7 +137,7 @@ test("prioritizes common read helpers and keeps destructive helpers last", () => assert.deepEqual(methodLabels.slice(0, 6), ["find", "findOne", "aggregate", "countDocuments", "estimatedDocumentCount", "distinct"]); assert.deepEqual(getCollectionMethodLabels.slice(0, 6), ["find", "findOne", "aggregate", "countDocuments", "estimatedDocumentCount", "distinct"]); assert.deepEqual(methodLabels.slice(-3), ["dropIndex", "dropIndexes", "drop"]); - assert.deepEqual(labels("db.users.find({})."), ["limit", "sort", "skip", "count"]); + assert.deepEqual(labels("db.users.find({})."), ["limit", "sort", "skip", "count", "explain"]); }); test("keeps dotted collection names ahead of methods until the collection is resolved", () => { @@ -162,7 +162,7 @@ test("suggests cursor methods after find result chains", () => { assert.deepEqual( allItems.map((item) => item.label), - ["limit", "sort", "skip", "count"], + ["limit", "sort", "skip", "count", "explain"], ); assert.deepEqual( prefixedItems.map((item) => item.label), @@ -170,7 +170,7 @@ test("suggests cursor methods after find result chains", () => { ); assert.deepEqual( formattedChainItems.map((item) => item.label), - ["limit", "sort", "skip", "count"], + ["limit", "sort", "skip", "count", "explain"], ); assert.deepEqual( formattedPrefixedItems.map((item) => item.label), diff --git a/packages/app-tests/mongoShellCommand.test.ts b/packages/app-tests/mongoShellCommand.test.ts index cf054bb7ef..5bf58cb2f0 100644 --- a/packages/app-tests/mongoShellCommand.test.ts +++ b/packages/app-tests/mongoShellCommand.test.ts @@ -6,6 +6,7 @@ import { evaluateMongoWriteSafety, mongoAggregateWriteStage, listChainedCalls, + parseMongoFindExplainCommand, mongoBulkWriteToQueryResult, mongoCollectionStatsToQueryResult, mongoCountToQueryResult, @@ -448,13 +449,43 @@ test("parseMongoFindCommand validates UUID strings at parse time", () => { assert.deepEqual(JSON.parse(command.filter), { u: { $uuid: "3B241101-E2BB-4255-8CAF-4136C566A962" } }); }); +test("parseMongoFindExplainCommand reads a final explain() with the find it wraps", () => { + assert.deepEqual(parseMongoFindExplainCommand('db.c.find({a: 1}, {b: 1}).sort({b: -1}).skip(2).limit(5).explain("allPlansExecution")'), { + collection: "c", + filter: '{"a": 1}', + projection: '{"b": 1}', + skip: 2, + limit: 5, + sort: '{"b": -1}', + verbosity: "allPlansExecution", + }); + assert.equal(parseMongoFindExplainCommand("db.c.find({a: 1}).explain()")?.verbosity, "queryPlanner"); + assert.equal(parseMongoFindExplainCommand("db.c.find({a: 1})\n .sort({b: 1})\n .explain('executionStats');")?.verbosity, "executionStats"); + assert.equal(parseMongoFindExplainCommand('db["x-y"].find({}).explain()')?.collection, "x-y"); + assert.equal(parseMongoCommand("db.c.find({}).explain()")?.command.kind, "findExplain"); + // A plain find is not an explain, and vice versa. + assert.equal(parseMongoFindExplainCommand("db.c.find({a: 1}).limit(1)"), null); + assert.equal(parseMongoFindCommand("db.c.find({a: 1}).explain()"), null); +}); + +test("parseMongoFindExplainCommand rejects a misplaced explain or an unknown verbosity", () => { + for (const [source, expected] of [ + ["db.c.find({}).explain('verbose')", /verbosity must be "queryPlanner", "executionStats" or "allPlansExecution", got 'verbose'/], + ["db.c.find({}).explain(1)", /verbosity must be .*, got 1/], + ["db.c.find({}).explain().limit(5)", /explain\(\) must be the final chain method/], + ["db.c.find({}).explain", /Unexpected text after find\(\.\.\.\): "\.explain"/], + ] as const) { + assert.equal(parseMongoCommand(source), null, source); + assert.match(describeMongoCommandParseFailure(source), expected, source); + } +}); + test("parseMongoFindCommand rejects chained methods it would otherwise silently drop", () => { // Each of these used to parse as a plain find and run a different query than written. for (const [source, expected] of [ - ["db.c.find({}).hint({a: 1})", /find\(\)\.hint\(\) is not supported yet\. Supported after find\(\): sort, skip, limit, collation, count, toArray, pretty\./], + ["db.c.find({}).hint({a: 1})", /find\(\)\.hint\(\) is not supported yet\. Supported after find\(\): sort, skip, limit, collation, count, explain, toArray, pretty\./], ["db.c.find({}).batchSize(10)", /batchSize\(\) is not supported yet/], ["db.c.find({}).sort({a: 1}).maxTimeMS(100)", /maxTimeMS\(\) is not supported yet/], - ["db.c.find({}).explain('executionStats')", /find\(\)\.explain\(\) is not supported in the editor yet; aggregate\(\[\.\.\.\], \{ explain: true \}\) is/], ["db.c.find({}).itcount()", /itcount\(\) is not supported; use find\(\)\.count\(\) or countDocuments\(\)/], ["db.c.find({}).size()", /size\(\) is not supported; use find\(\)\.count\(\)/], ["db.c.find({}).forEach(d => print(d))", /forEach\(\) runs JavaScript, which the editor does not execute/], diff --git a/src-tauri/src/commands/mongo_cmd.rs b/src-tauri/src/commands/mongo_cmd.rs index ec9621910b..733d1a49e6 100644 --- a/src-tauri/src/commands/mongo_cmd.rs +++ b/src-tauri/src/commands/mongo_cmd.rs @@ -144,6 +144,47 @@ pub async fn mongo_find_documents( .await } +#[tauri::command] +#[allow(clippy::too_many_arguments)] +pub async fn mongo_explain_find( + state: State<'_, Arc>, + connection_id: String, + database: String, + collection: String, + skip: u64, + limit: i64, + filter: Option, + projection: Option, + sort: Option, + collation: Option, + verbosity: Option, + execution_id: Option, + mcp_request: Option, +) -> Result { + let app = state.inner().clone(); + if mcp_request == Some(true) { + crate::commands::mcp_bridge::ensure_mcp_read_allowed_by_id(&app, &connection_id, &database).await?; + } + run_cancellable( + &app, + execution_id, + dbx_core::mongo_ops::mongo_explain_find_core( + &app, + &connection_id, + &database, + &collection, + skip, + limit, + filter.as_deref(), + projection.as_deref(), + sort.as_deref(), + collation.as_deref(), + verbosity.as_deref().unwrap_or("queryPlanner"), + ), + ) + .await +} + #[tauri::command] #[allow(clippy::too_many_arguments)] pub async fn mongo_find_one( diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b8d150e423..a1dd26e9b0 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2325,6 +2325,7 @@ pub fn run() { commands::mongo_cmd::mongo_find_documents, commands::mongo_cmd::mongo_parse_shell_command, commands::mongo_cmd::mongo_find_one, + commands::mongo_cmd::mongo_explain_find, commands::mongo_cmd::mongo_count_documents, commands::mongo_cmd::mongo_server_version, commands::mongo_cmd::mongo_collection_stats,