feat(mongodb): support find explain in the SQL editor
This commit is contained in:
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 });
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user