From 63f375a7d8e00645262bf62fb603ca9c21cd0c8d Mon Sep 17 00:00:00 2001 From: James Leong <109658865+James-Leong@users.noreply.github.com> Date: Wed, 22 Jul 2026 15:10:40 +0800 Subject: [PATCH] fix(mongo): ignore comments in shell JSON arguments --- packages/app-tests/mongoShellCommand.test.ts | 100 +++++++++++++++--- packages/mongo-shell/src/index.ts | 1 + packages/mongo-shell/src/json.ts | 104 +++++++++++++++---- 3 files changed, 175 insertions(+), 30 deletions(-) diff --git a/packages/app-tests/mongoShellCommand.test.ts b/packages/app-tests/mongoShellCommand.test.ts index bb5d4e268..63500a98c 100644 --- a/packages/app-tests/mongoShellCommand.test.ts +++ b/packages/app-tests/mongoShellCommand.test.ts @@ -41,18 +41,9 @@ test("parseMongoFindCommand parses db collection find with an empty JSON filter" }); test("normalizeRustMongoCommand preserves the desktop command contract", () => { - assert.deepEqual( - normalizeRustMongoCommand({ kind: "countDocuments", collection: "users", filter: "{}", accurate: false }), - { kind: "countDocuments", collection: "users", filter: "{}", mode: "legacy" }, - ); - assert.deepEqual( - normalizeRustMongoCommand({ kind: "dropIndexes", collection: "users", indexes: '"email_1"', single: true }), - { kind: "dropIndex", collection: "users", index: '"email_1"' }, - ); - assert.deepEqual( - normalizeRustMongoCommand({ kind: "findOne", collection: "users", filter: "{}", projection: null, options: null }), - { kind: "findOne", collection: "users", filter: "{}" }, - ); + assert.deepEqual(normalizeRustMongoCommand({ kind: "countDocuments", collection: "users", filter: "{}", accurate: false }), { kind: "countDocuments", collection: "users", filter: "{}", mode: "legacy" }); + assert.deepEqual(normalizeRustMongoCommand({ kind: "dropIndexes", collection: "users", indexes: '"email_1"', single: true }), { kind: "dropIndex", collection: "users", index: '"email_1"' }); + assert.deepEqual(normalizeRustMongoCommand({ kind: "findOne", collection: "users", filter: "{}", projection: null, options: null }), { kind: "findOne", collection: "users", filter: "{}" }); }); test("parseMongoFindCommand parses getCollection find with chained sort skip and limit", () => { @@ -533,6 +524,91 @@ test("parseMongoAggregateCommand accepts an empty pipeline", () => { }); }); +test("parseMongoAggregateCommand ignores comments inside aggregate pipelines", () => { + const command = parseMongoAggregateCommand(` + db.cash.aggregate([ + { + $match: { + portfolio_id: "b6f6ec62-8571-11f1-bbfb-000c29caf77f", + date: { $gte: "20260604" } + } + }, + { $unwind: "$cashs" }, + { + $project: { + _id: 0, + date: 1, + trans_currency_cd: "$cashs.trans_currency_cd", + cash_local: { $multiply: ["$cashs.cash", "$cashs.fx_rate"] } + } + }, + { + $group: { + _id: { date: "$date", currency: "$trans_currency_cd" }, + cash_local: { $sum: "$cash_local" } + } + }, + // 第二步:按 date 分组,将不同币种转为字段 + { + $group: { + _id: "$_id.date", + cny: { + $sum: { + $cond: [{ $eq: ["$_id.currency", "CNY"] }, "$cash_local", 0] + } + }, + hkd: { + $sum: { + $cond: [{ $eq: ["$_id.currency", "HKD"] }, "$cash_local", 0] + } + }, + total_cash: { $sum: "$cash_local" } + } + }, + { $sort: { _id: 1 } }, + /* 可选:将 _id 重命名为 date */ + { + $project: { + _id: 0, + date: "$_id", + cny: 1, + hkd: 1, + total_cash: 1 + } + } + ]) + `); + + assert.ok(command); + assert.equal(command.collection, "cash"); + const pipeline = JSON.parse(command.pipeline); + assert.equal(pipeline.length, 7); + assert.deepEqual(pipeline[4].$group.total_cash, { $sum: "$cash_local" }); +}); + +test("parseMongoAggregateCommand keeps comment markers inside string values", () => { + const command = parseMongoAggregateCommand(`db.logs.aggregate([ + { $match: { url: "https://example.com/a//b", note: "literal /* text */" } }, + // comment with closing delimiters )]} + { $project: { url: 1, note: 1 } } + ])`); + + assert.ok(command); + assert.deepEqual(JSON.parse(command.pipeline)[0].$match, { + url: "https://example.com/a//b", + note: "literal /* text */", + }); +}); + +test("parseMongoAggregateCommand accepts every JavaScript line terminator after comments", () => { + for (const lineTerminator of ["\n", "\r", "\r\n", "\u2028", "\u2029"]) { + const command = parseMongoAggregateCommand(`db.logs.aggregate([// pipeline${lineTerminator}{ $match: { active: true } }])`); + + assert.ok(command, `expected parser result for ${JSON.stringify(lineTerminator)}`); + assert.deepEqual(JSON.parse(command.pipeline), [{ $match: { active: true } }]); + } +}); + test("parseMongoAggregateCommand accepts official aggregate options document", () => { assert.deepEqual(parseMongoAggregateCommand("db.products.aggregate([], {})"), { collection: "products", diff --git a/packages/mongo-shell/src/index.ts b/packages/mongo-shell/src/index.ts index 324287c7d..aef5c303b 100644 --- a/packages/mongo-shell/src/index.ts +++ b/packages/mongo-shell/src/index.ts @@ -9,6 +9,7 @@ export { parseMongoObjectArgument, quoteUnquotedObjectKeys, splitTopLevel, + stripMongoJsonComments, trimMongoOuterComments, } from "./json.js"; diff --git a/packages/mongo-shell/src/json.ts b/packages/mongo-shell/src/json.ts index 5f3b03c2d..cf977cd03 100644 --- a/packages/mongo-shell/src/json.ts +++ b/packages/mongo-shell/src/json.ts @@ -7,9 +7,11 @@ export function normalizeJsonArgument(value: string): string | null { const trimmed = value.trim(); if (!trimmed) return "{}"; + const withoutComments = stripMongoJsonComments(trimmed).trim(); + if (!withoutComments) return "{}"; // Rewrite mongo shell constructors that are not valid JSON into extended JSON // (mongo_driver::json_value_to_bson): ObjectId / NumberLong / ISODate / new Date. - const withExtendedJson = replaceMongoShellConstructors(trimmed); + const withExtendedJson = replaceMongoShellConstructors(withoutComments); const preprocessed = quoteUnquotedObjectKeys(convertSingleQuotedStrings(withExtendedJson)); try { JSON.parse(preprocessed); @@ -32,18 +34,13 @@ export function parseMongoObjectArgument(arg: string | undefined): string | null } } -export function parseCollectionMethodTarget( - source: string, - method: string, -): { collection: string; methodCallIndex: number } | null { +export function parseCollectionMethodTarget(source: string, method: string): { collection: string; methodCallIndex: number } | null { 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 getCollection = new RegExp( - `^db\\s*\\.\\s*getCollection\\s*\\(\\s*(["'])(.*?)\\1\\s*\\)\\s*\\.\\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: findChainedMethodCallIndex(source, method) }; } @@ -78,6 +75,12 @@ export function splitTopLevel(source: string): string[] { continue; } + const commentEnd = mongoCommentEndAt(source, i); + if (commentEnd !== null) { + i = commentEnd - 1; + continue; + } + if (char === '"' || char === "'") quote = char; else if (char === "{" || char === "[" || char === "(") depth += 1; else if (char === "}" || char === "]" || char === ")") depth -= 1; @@ -106,6 +109,12 @@ export function findMatchingParen(source: string, openIndex: number): number { continue; } + const commentEnd = mongoCommentEndAt(source, i); + if (commentEnd !== null) { + i = commentEnd - 1; + continue; + } + if (char === '"' || char === "'") quote = char; else if (char === "(") depth += 1; else if (char === ")") { @@ -130,6 +139,11 @@ export function hasUnclosedMongoDelimiters(source: string): boolean { else if (char === quote) quote = null; continue; } + const commentEnd = mongoCommentEndAt(source, i); + if (commentEnd !== null) { + i = commentEnd - 1; + continue; + } if (char === '"' || char === "'") { quote = char; continue; @@ -146,14 +160,52 @@ export function hasUnclosedMongoDelimiters(source: string): boolean { return quote !== null || stack.length > 0; } +/** Remove shell/SQL-style comments from JSON-like Mongo arguments. */ +export function stripMongoJsonComments(source: string): string { + let result = ""; + let quote: string | null = null; + let escaped = false; + + for (let i = 0; i < source.length; i += 1) { + const char = source[i] ?? ""; + if (quote) { + result += char; + if (escaped) escaped = false; + else if (char === "\\") escaped = true; + else if (char === quote) quote = null; + continue; + } + + if (char === '"' || char === "'") { + quote = char; + result += char; + continue; + } + + const commentEnd = mongoCommentEndAt(source, i); + if (commentEnd !== null) { + result += source + .slice(i, commentEnd) + .replace(/[^\n\r\u2028\u2029]/g, " ") + .replace(/[\u2028\u2029]/g, "\n"); + i = commentEnd - 1; + continue; + } + + result += char; + } + + return result; +} + /** Strip leading line/block comments (//, --, and block comments). */ export function trimMongoOuterComments(source: string): string { let text = source; for (;;) { const trimmed = text.trimStart(); if (trimmed.startsWith("//") || trimmed.startsWith("--")) { - const nl = trimmed.indexOf("\n"); - text = nl < 0 ? "" : trimmed.slice(nl + 1); + const end = mongoLineCommentEnd(trimmed, 2); + text = end >= trimmed.length ? "" : trimmed.slice(end); continue; } if (trimmed.startsWith("/*")) { @@ -166,6 +218,28 @@ export function trimMongoOuterComments(source: string): string { } } +function mongoCommentEndAt(source: string, index: number): number | null { + const current = source[index]; + const next = source[index + 1]; + if ((current === "/" && next === "/") || (current === "-" && next === "-")) { + return mongoLineCommentEnd(source, index + 2); + } + if (current === "/" && next === "*") { + const end = source.indexOf("*/", index + 2); + return end < 0 ? source.length : end + 2; + } + return null; +} + +function mongoLineCommentEnd(source: string, start: number): number { + for (let index = start; index < source.length; index += 1) { + const char = source[index]; + if (char === "\r") return source[index + 1] === "\n" ? index + 2 : index + 1; + if (char === "\n" || char === "\u2028" || char === "\u2029") return index + 1; + } + return source.length; +} + export function quoteUnquotedObjectKeys(source: string): string { let result = ""; let quote: string | null = null; @@ -213,8 +287,7 @@ function shouldQuoteObjectKey(source: string, index: number): boolean { } function replaceMongoShellConstructors(source: string): string { - const constructor = - /^(ObjectId|NumberLong|ISODate)\s*\(\s*["']([^"']+)["']\s*\)|^(ObjectId|NumberLong)\s*\(\s*(-?\d+)\s*\)|^(?:new\s+Date)\s*\(\s*["']([^"']+)["']\s*\)/; + const constructor = /^(ObjectId|NumberLong|ISODate)\s*\(\s*["']([^"']+)["']\s*\)|^(ObjectId|NumberLong)\s*\(\s*(-?\d+)\s*\)|^(?:new\s+Date)\s*\(\s*["']([^"']+)["']\s*\)/; let result = ""; let index = 0; while (index < source.length) { @@ -237,12 +310,7 @@ function replaceMongoShellConstructors(source: string): string { continue; } if (match[1]) { - result += - match[1] === "ObjectId" - ? `{"$oid":"${match[2]}"}` - : match[1] === "NumberLong" - ? `{"$numberLong":"${match[2]}"}` - : `{"$date":"${match[2]}"}`; + result += match[1] === "ObjectId" ? `{"$oid":"${match[2]}"}` : match[1] === "NumberLong" ? `{"$numberLong":"${match[2]}"}` : `{"$date":"${match[2]}"}`; } else if (match[3]) { result += match[3] === "NumberLong" ? `{"$numberLong":"${match[4]}"}` : `{"$oid":"${match[4]}"}`; } else {