fix(mongodb): reject unsupported find() chain methods instead of dropping them

This commit is contained in:
Loc Nguyen authored and GitHub committed 2026-09-17 17:16:28 +08:00
1 parent 6d2c8a5edb
commit 58200ce5a4
2 files changed
+115 -2

No files matched your search

@@ -160,6 +160,13 @@ function diagnoseMongoCommand(source: string): string | null {
}
const tail = source.slice(closeIndex + 1).trim();
if (tail && method === "find") {
const chainProblem = describeUnsupportedFindChain(tail);
if (chainProblem) return chainProblem;
}
if (tail && (method === "findOne" || method === "countDocuments" || method === "count" || method === "distinct")) {
return `${method}() returns a result, not a cursor, so nothing can be chained after it: "${tail.length > 40 ? `${tail.slice(0, 40)}…` : tail}".`;
}
if (tail) return `Unexpected text after ${method}(...): "${tail.length > 40 ? `${tail.slice(0, 40)}…` : tail}".`;
if (method === "bulkWrite" && args[0]) {
const operations = normalizeJsonArgument(args[0]);
@@ -355,8 +362,11 @@ export function parseMongoFindCommand(input: string): MongoFindCommand | null {
}
const chain = source.slice(findCloseIndex + 1).trim();
if (chain && !chain.startsWith(".")) return null;
if (findChainedMethodCallIndex(chain, "count") >= 0) return null;
// Every chained call must be one the editor executes; dropping an unknown one
// (`.hint()`, `.forEach()`, `.explain()`) would run a different query than written.
const calls = listChainedCalls(chain);
if (!calls || calls.some((call) => !FIND_CHAIN_METHODS.has(call.name) || (isNoopCursorMethod(call.name) && call.args.trim()))) return null;
if (calls.some((call) => call.name === "count")) return null;
const sortArg = readChainedCallArgument(chain, "sort");
let sort: string | undefined;
@@ -1554,6 +1564,66 @@ function readChainedCallArgument(source: string, name: string): string | undefin
return undefined;
}
/** Cursor methods the editor executes after `find()`. `toArray` / `pretty` change nothing and are dropped. */
const FIND_CHAIN_METHODS = new Set(["sort", "skip", "limit", "collation", "count", "toArray", "pretty"]);
function isNoopCursorMethod(name: string): boolean {
return name === "toArray" || name === "pretty";
}
interface ChainedCall {
name: string;
/** Raw text between the parentheses. */
args: string;
/** Offset of the `.` within the chain text. */
index: number;
}
/**
* Tokenise `.name(args).name(args)…`. Returns null when the chain is not a sequence
* of calls, e.g. a property access such as `.count` without parentheses or trailing text.
*/
export function listChainedCalls(chain: string): ChainedCall[] | null {
const calls: ChainedCall[] = [];
let index = 0;
while (index < chain.length) {
const rest = chain.slice(index);
if (!rest.trim()) break;
const match = /^\s*\.\s*([A-Za-z_$][\w$]*)\s*\(/.exec(rest);
if (!match) return null;
const openIndex = index + match[0].length - 1;
const closeIndex = findMatchingParen(chain, openIndex);
if (closeIndex < 0) return null;
calls.push({ name: match[1]!, args: chain.slice(openIndex + 1, closeIndex), index: index + match[0].indexOf(".") });
index = closeIndex + 1;
}
return calls;
}
/** Why a `find()` chain was rejected, for the parse-failure diagnostics. */
function describeUnsupportedFindChain(chain: string): string | null {
const calls = listChainedCalls(chain);
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.";
switch (offending.name) {
case "explain":
return `find().explain() is not supported in the editor yet; aggregate([...], { explain: true }) is. ${supported}`;
case "itcount":
case "size":
return `find().${offending.name}() is not supported; use find().count() or countDocuments() to count results. ${supported}`;
case "forEach":
case "map":
return `find().${offending.name}() runs JavaScript, which the editor does not execute; run the find and read the results instead.`;
case "toArray":
case "pretty":
return `find().${offending.name}() takes no arguments.`;
default:
return `find().${offending.name}() is not supported yet. ${supported}`;
}
}
function hasSingleEmptyChainedCall(source: string, name: string): boolean {
const trimmed = source.trim();
const match = chainedMethodCallPattern(name).exec(trimmed);