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

This commit is contained in:
Loc Nguyen authored and GitHub committed 2026-09-18 01:45:37 +08:00
1 parent 2e85c8b55d
commit a78fc48a7b
15 files changed
+290 -9

No files matched your search

@@ -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);