feat(mongodb): support find explain in the SQL editor

This commit is contained in:
Loc Nguyen
2026-09-18 01:45:37 +08:00
committed by GitHub
parent 2e85c8b55d
commit a78fc48a7b
15 changed files with 290 additions and 9 deletions
@@ -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");
@@ -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 });
});
});
+1
View File
@@ -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");
+16
View File
@@ -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<unknown> {
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<MongoBulkWriteResult> {
return post("/api/mongo/bulk-write", {
connectionId,
+16
View File
@@ -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<unknown> {
return invoke<unknown>("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<MongoBulkWriteResult> {
return invoke<MongoBulkWriteResult>("mongo_bulk_write", {
connectionId,
@@ -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();
@@ -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;
/**
@@ -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}`;
+1 -1
View File
@@ -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<MongoCommand["kind"]>(["find", "findOne", "countDocuments", "distinct", "getIndexes", "collectionStats", "version", "showDatabases", "use"]);
const MONGO_READ_KINDS = new Set<MongoCommand["kind"]>(["find", "findExplain", "findOne", "countDocuments", "distinct", "getIndexes", "collectionStats", "version", "showDatabases", "use"]);
const MONGO_DDL_KINDS = new Set<MongoCommand["kind"]>(["createIndex", "dropIndex", "dropIndexes", "dropCollection", "createUser"]);
/**
@@ -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 });
});
});
+22
View File
@@ -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);
+3 -3
View File
@@ -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),
+33 -2
View File
@@ -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/],
+41
View File
@@ -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<AppState>>,
connection_id: String,
database: String,
collection: String,
skip: u64,
limit: i64,
filter: Option<String>,
projection: Option<String>,
sort: Option<String>,
collation: Option<String>,
verbosity: Option<String>,
execution_id: Option<String>,
mcp_request: Option<bool>,
) -> Result<serde_json::Value, String> {
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(
+1
View File
@@ -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,