fix(mongo): parse newline chained find calls

This commit is contained in:
t8y2 2026-06-24 23:21:06 +08:00
parent b89df5a43c
commit bc49bbbbfa
4 changed files with 68 additions and 25 deletions

View File

@ -305,19 +305,19 @@ function parseFindTarget(source: string): { collection: string; findCallIndex: n
function parseCollectionMethodTarget(source: string, method: string): { collection: string; methodCallIndex: number } | null {
const escapedMethod = escapeRegExp(method);
const direct = new RegExp(`^db\\.([A-Za-z_$][\\w$]*)\\.${escapedMethod}\\s*\\(`).exec(source);
const direct = new RegExp(`^db\\s*\\.\\s*([A-Za-z_$][\\w$]*)\\s*\\.\\s*${escapedMethod}\\s*\\(`).exec(source);
if (direct) {
return {
collection: direct[1],
methodCallIndex: source.indexOf(`.${method}`, direct[0].length - `.${method}(`.length),
methodCallIndex: findChainedMethodCallIndex(source, method),
};
}
const getCollection = new RegExp(`^db\\.getCollection\\s*\\(\\s*(["'])(.*?)\\1\\s*\\)\\.${escapedMethod}\\s*\\(`).exec(source);
const getCollection = new RegExp(`^db\\s*\\.\\s*getCollection\\s*\\(\\s*(["'])(.*?)\\1\\s*\\)\\s*\\.\\s*${escapedMethod}\\s*\\(`).exec(source);
if (getCollection) {
return {
collection: getCollection[2],
methodCallIndex: source.indexOf(`.${method}`, getCollection[0].length - `.${method}(`.length),
methodCallIndex: findChainedMethodCallIndex(source, method),
};
}
@ -444,24 +444,23 @@ function readChainedIntegerArgument(source: string, name: string, fallback: numb
}
function readChainedCallArgument(source: string, name: string): string | undefined {
const call = `.${name}`;
let index = source.indexOf(call);
while (index >= 0) {
const afterName = index + call.length;
const openIndex = skipWhitespace(source, afterName);
if (source[openIndex] === "(") {
const closeIndex = findMatchingParen(source, openIndex);
if (closeIndex >= 0) return source.slice(openIndex + 1, closeIndex);
}
index = source.indexOf(call, afterName);
const pattern = chainedMethodCallPattern(name);
let match = pattern.exec(source);
while (match) {
const openIndex = source.indexOf("(", match.index);
const closeIndex = findMatchingParen(source, openIndex);
if (closeIndex >= 0) return source.slice(openIndex + 1, closeIndex);
match = pattern.exec(source);
}
return undefined;
}
function skipWhitespace(source: string, index: number) {
let cursor = index;
while (/\s/.test(source[cursor] || "")) cursor += 1;
return cursor;
function findChainedMethodCallIndex(source: string, name: string): number {
return chainedMethodCallPattern(name).exec(source)?.index ?? -1;
}
function chainedMethodCallPattern(name: string): RegExp {
return new RegExp(`\\.\\s*${escapeRegExp(name)}\\s*\\(`, "g");
}
function splitTopLevel(source: string): string[] {

View File

@ -33,6 +33,22 @@ test("parseMongoFindCommand parses getCollection find with chained sort skip and
});
});
test("parseMongoFindCommand accepts line breaks before find and chained calls", () => {
const command = parseMongoFindCommand(`db.getCollection("accounting_reconciliations")
.find({
"_id": ObjectId("68ad51ca84c8127bc7d44cb3")
})
.sort({ lineNo: -1 })
.skip(5)
.limit(20)`);
assert.ok(command);
assert.equal(command.collection, "accounting_reconciliations");
assert.deepEqual(JSON.parse(command.filter), { _id: { $oid: "68ad51ca84c8127bc7d44cb3" } });
assert.deepEqual(JSON.parse(command.sort || "{}"), { lineNo: -1 });
assert.equal(command.skip, 5);
assert.equal(command.limit, 20);
});
test("parseMongoFindCommand accepts Compass-style unquoted keys and ObjectId", () => {
const command = parseMongoFindCommand("db.products.find({_id: ObjectId('6a045a92d2971e44243771a1')}).limit(1)");
assert.ok(command);

View File

@ -921,10 +921,11 @@ export function evaluateMongoAggregateSafety(command: MongoAggregateCommand, opt
}
function parseCollectionMethodTarget(source: string, method: string): { collection: string; methodCallIndex: number } | null {
const direct = new RegExp(`^db\\.([A-Za-z_$][\\w$]*)\\.${method}\\s*\\(`).exec(source);
if (direct) return { collection: direct[1], methodCallIndex: source.indexOf(`.${method}`) };
const quoted = new RegExp(`^db\\.getCollection\\(\\s*(['"])([^'"]+)\\1\\s*\\)\\.${method}\\s*\\(`).exec(source);
if (quoted) return { collection: quoted[2], methodCallIndex: source.indexOf(`.${method}`) };
const escapedMethod = escapeRegExp(method);
const direct = new RegExp(`^db\\s*\\.\\s*([A-Za-z_$][\\w$]*)\\s*\\.\\s*${escapedMethod}\\s*\\(`).exec(source);
if (direct) return { collection: direct[1], methodCallIndex: findChainedMethodCallIndex(source, method) };
const quoted = new RegExp(`^db\\s*\\.\\s*getCollection\\s*\\(\\s*(['"])([^'"]+)\\1\\s*\\)\\s*\\.\\s*${escapedMethod}\\s*\\(`).exec(source);
if (quoted) return { collection: quoted[2], methodCallIndex: findChainedMethodCallIndex(source, method) };
return null;
}
@ -936,14 +937,25 @@ function parseMethodArgs(source: string, methodCallIndex: number): string[] | nu
}
function readChainedCallArgument(chain: string, method: string): string | undefined {
const pattern = new RegExp(`\\.${method}\\s*\\(`, "g");
const match = pattern.exec(chain);
const match = chainedMethodCallPattern(method).exec(chain);
if (!match) return undefined;
const openIndex = match.index + match[0].lastIndexOf("(");
const openIndex = chain.indexOf("(", match.index);
const closeIndex = findMatchingParen(chain, openIndex);
return closeIndex < 0 ? undefined : chain.slice(openIndex + 1, closeIndex);
}
function findChainedMethodCallIndex(source: string, method: string): number {
return chainedMethodCallPattern(method).exec(source)?.index ?? -1;
}
function chainedMethodCallPattern(method: string): RegExp {
return new RegExp(`\\.\\s*${escapeRegExp(method)}\\s*\\(`, "g");
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function readChainedIntegerArgument(chain: string, method: string, fallback: number): number | null {
const arg = readChainedCallArgument(chain, method);
if (arg === undefined) return fallback;

View File

@ -12,6 +12,22 @@ test("parseMongoFindCommand accepts shell-style find commands", () => {
});
});
test("parseMongoFindCommand accepts line breaks before find and chained calls", () => {
const command = parseMongoFindCommand(`db.getCollection("operation_logs")
.find({
"_id": ObjectId("68ad51ca84c8127bc7d44cb3")
})
.sort({ ts: -1 })
.skip(5)
.limit(10)`);
assert.ok(command);
assert.equal(command.collection, "operation_logs");
assert.deepEqual(JSON.parse(command.filter), { _id: { $oid: "68ad51ca84c8127bc7d44cb3" } });
assert.deepEqual(JSON.parse(command.sort || "{}"), { ts: -1 });
assert.equal(command.skip, 5);
assert.equal(command.limit, 10);
});
test("parseMongoFindCommand accepts Compass-style unquoted keys and ObjectId", () => {
const command = parseMongoFindCommand("db.products.find({_id: ObjectId('6a045a92d2971e44243771a1')}).limit(1)");
assert.ok(command);