fix(mongo): ignore comments in shell JSON arguments
This commit is contained in:
1 parent
94a40a8025
commit
63f375a7d8
3 files changed
+175
-30
No files matched your search
+88
-12
@@ -41,18 +41,9 @@ test("parseMongoFindCommand parses db collection find with an empty JSON filter"
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("normalizeRustMongoCommand preserves the desktop command contract", () => {
|
test("normalizeRustMongoCommand preserves the desktop command contract", () => {
|
||||||
assert.deepEqual(
|
assert.deepEqual(normalizeRustMongoCommand({ kind: "countDocuments", collection: "users", filter: "{}", accurate: false }), { kind: "countDocuments", collection: "users", filter: "{}", mode: "legacy" });
|
||||||
normalizeRustMongoCommand({ kind: "countDocuments", collection: "users", filter: "{}", accurate: false }),
|
assert.deepEqual(normalizeRustMongoCommand({ kind: "dropIndexes", collection: "users", indexes: '"email_1"', single: true }), { kind: "dropIndex", collection: "users", index: '"email_1"' });
|
||||||
{ kind: "countDocuments", collection: "users", filter: "{}", mode: "legacy" },
|
assert.deepEqual(normalizeRustMongoCommand({ kind: "findOne", collection: "users", filter: "{}", projection: null, options: null }), { kind: "findOne", collection: "users", filter: "{}" });
|
||||||
);
|
|
||||||
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", () => {
|
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", () => {
|
test("parseMongoAggregateCommand accepts official aggregate options document", () => {
|
||||||
assert.deepEqual(parseMongoAggregateCommand("db.products.aggregate([], {})"), {
|
assert.deepEqual(parseMongoAggregateCommand("db.products.aggregate([], {})"), {
|
||||||
collection: "products",
|
collection: "products",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export {
|
|||||||
parseMongoObjectArgument,
|
parseMongoObjectArgument,
|
||||||
quoteUnquotedObjectKeys,
|
quoteUnquotedObjectKeys,
|
||||||
splitTopLevel,
|
splitTopLevel,
|
||||||
|
stripMongoJsonComments,
|
||||||
trimMongoOuterComments,
|
trimMongoOuterComments,
|
||||||
} from "./json.js";
|
} from "./json.js";
|
||||||
|
|
||||||
|
|||||||
@@ -7,9 +7,11 @@
|
|||||||
export function normalizeJsonArgument(value: string): string | null {
|
export function normalizeJsonArgument(value: string): string | null {
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
if (!trimmed) return "{}";
|
if (!trimmed) return "{}";
|
||||||
|
const withoutComments = stripMongoJsonComments(trimmed).trim();
|
||||||
|
if (!withoutComments) return "{}";
|
||||||
// Rewrite mongo shell constructors that are not valid JSON into extended JSON
|
// Rewrite mongo shell constructors that are not valid JSON into extended JSON
|
||||||
// (mongo_driver::json_value_to_bson): ObjectId / NumberLong / ISODate / new Date.
|
// (mongo_driver::json_value_to_bson): ObjectId / NumberLong / ISODate / new Date.
|
||||||
const withExtendedJson = replaceMongoShellConstructors(trimmed);
|
const withExtendedJson = replaceMongoShellConstructors(withoutComments);
|
||||||
const preprocessed = quoteUnquotedObjectKeys(convertSingleQuotedStrings(withExtendedJson));
|
const preprocessed = quoteUnquotedObjectKeys(convertSingleQuotedStrings(withExtendedJson));
|
||||||
try {
|
try {
|
||||||
JSON.parse(preprocessed);
|
JSON.parse(preprocessed);
|
||||||
@@ -32,18 +34,13 @@ export function parseMongoObjectArgument(arg: string | undefined): string | null
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseCollectionMethodTarget(
|
export function parseCollectionMethodTarget(source: string, method: string): { collection: string; methodCallIndex: number } | null {
|
||||||
source: string,
|
|
||||||
method: string,
|
|
||||||
): { collection: string; methodCallIndex: number } | null {
|
|
||||||
const escapedMethod = escapeRegExp(method);
|
const escapedMethod = escapeRegExp(method);
|
||||||
const direct = new RegExp(`^db\\s*\\.\\s*([A-Za-z_$][\\w$]*)\\s*\\.\\s*${escapedMethod}\\s*\\(`).exec(source);
|
const direct = new RegExp(`^db\\s*\\.\\s*([A-Za-z_$][\\w$]*)\\s*\\.\\s*${escapedMethod}\\s*\\(`).exec(source);
|
||||||
if (direct) {
|
if (direct) {
|
||||||
return { collection: direct[1]!, methodCallIndex: findChainedMethodCallIndex(source, method) };
|
return { collection: direct[1]!, methodCallIndex: findChainedMethodCallIndex(source, method) };
|
||||||
}
|
}
|
||||||
const getCollection = new RegExp(
|
const getCollection = new RegExp(`^db\\s*\\.\\s*getCollection\\s*\\(\\s*(["'])(.*?)\\1\\s*\\)\\s*\\.\\s*${escapedMethod}\\s*\\(`).exec(source);
|
||||||
`^db\\s*\\.\\s*getCollection\\s*\\(\\s*(["'])(.*?)\\1\\s*\\)\\s*\\.\\s*${escapedMethod}\\s*\\(`,
|
|
||||||
).exec(source);
|
|
||||||
if (getCollection) {
|
if (getCollection) {
|
||||||
return { collection: getCollection[2]!, methodCallIndex: findChainedMethodCallIndex(source, method) };
|
return { collection: getCollection[2]!, methodCallIndex: findChainedMethodCallIndex(source, method) };
|
||||||
}
|
}
|
||||||
@@ -78,6 +75,12 @@ export function splitTopLevel(source: string): string[] {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const commentEnd = mongoCommentEndAt(source, i);
|
||||||
|
if (commentEnd !== null) {
|
||||||
|
i = commentEnd - 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (char === '"' || char === "'") quote = char;
|
if (char === '"' || char === "'") quote = char;
|
||||||
else if (char === "{" || char === "[" || char === "(") depth += 1;
|
else if (char === "{" || char === "[" || char === "(") depth += 1;
|
||||||
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const commentEnd = mongoCommentEndAt(source, i);
|
||||||
|
if (commentEnd !== null) {
|
||||||
|
i = commentEnd - 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (char === '"' || char === "'") quote = char;
|
if (char === '"' || char === "'") quote = char;
|
||||||
else if (char === "(") depth += 1;
|
else if (char === "(") depth += 1;
|
||||||
else if (char === ")") {
|
else if (char === ")") {
|
||||||
@@ -130,6 +139,11 @@ export function hasUnclosedMongoDelimiters(source: string): boolean {
|
|||||||
else if (char === quote) quote = null;
|
else if (char === quote) quote = null;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
const commentEnd = mongoCommentEndAt(source, i);
|
||||||
|
if (commentEnd !== null) {
|
||||||
|
i = commentEnd - 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (char === '"' || char === "'") {
|
if (char === '"' || char === "'") {
|
||||||
quote = char;
|
quote = char;
|
||||||
continue;
|
continue;
|
||||||
@@ -146,14 +160,52 @@ export function hasUnclosedMongoDelimiters(source: string): boolean {
|
|||||||
return quote !== null || stack.length > 0;
|
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). */
|
/** Strip leading line/block comments (//, --, and block comments). */
|
||||||
export function trimMongoOuterComments(source: string): string {
|
export function trimMongoOuterComments(source: string): string {
|
||||||
let text = source;
|
let text = source;
|
||||||
for (;;) {
|
for (;;) {
|
||||||
const trimmed = text.trimStart();
|
const trimmed = text.trimStart();
|
||||||
if (trimmed.startsWith("//") || trimmed.startsWith("--")) {
|
if (trimmed.startsWith("//") || trimmed.startsWith("--")) {
|
||||||
const nl = trimmed.indexOf("\n");
|
const end = mongoLineCommentEnd(trimmed, 2);
|
||||||
text = nl < 0 ? "" : trimmed.slice(nl + 1);
|
text = end >= trimmed.length ? "" : trimmed.slice(end);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (trimmed.startsWith("/*")) {
|
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 {
|
export function quoteUnquotedObjectKeys(source: string): string {
|
||||||
let result = "";
|
let result = "";
|
||||||
let quote: string | null = null;
|
let quote: string | null = null;
|
||||||
@@ -213,8 +287,7 @@ function shouldQuoteObjectKey(source: string, index: number): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function replaceMongoShellConstructors(source: string): string {
|
function replaceMongoShellConstructors(source: string): string {
|
||||||
const constructor =
|
const constructor = /^(ObjectId|NumberLong|ISODate)\s*\(\s*["']([^"']+)["']\s*\)|^(ObjectId|NumberLong)\s*\(\s*(-?\d+)\s*\)|^(?:new\s+Date)\s*\(\s*["']([^"']+)["']\s*\)/;
|
||||||
/^(ObjectId|NumberLong|ISODate)\s*\(\s*["']([^"']+)["']\s*\)|^(ObjectId|NumberLong)\s*\(\s*(-?\d+)\s*\)|^(?:new\s+Date)\s*\(\s*["']([^"']+)["']\s*\)/;
|
|
||||||
let result = "";
|
let result = "";
|
||||||
let index = 0;
|
let index = 0;
|
||||||
while (index < source.length) {
|
while (index < source.length) {
|
||||||
@@ -237,12 +310,7 @@ function replaceMongoShellConstructors(source: string): string {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (match[1]) {
|
if (match[1]) {
|
||||||
result +=
|
result += match[1] === "ObjectId" ? `{"$oid":"${match[2]}"}` : match[1] === "NumberLong" ? `{"$numberLong":"${match[2]}"}` : `{"$date":"${match[2]}"}`;
|
||||||
match[1] === "ObjectId"
|
|
||||||
? `{"$oid":"${match[2]}"}`
|
|
||||||
: match[1] === "NumberLong"
|
|
||||||
? `{"$numberLong":"${match[2]}"}`
|
|
||||||
: `{"$date":"${match[2]}"}`;
|
|
||||||
} else if (match[3]) {
|
} else if (match[3]) {
|
||||||
result += match[3] === "NumberLong" ? `{"$numberLong":"${match[4]}"}` : `{"$oid":"${match[4]}"}`;
|
result += match[3] === "NumberLong" ? `{"$numberLong":"${match[4]}"}` : `{"$oid":"${match[4]}"}`;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
Reference in new issue
Block a user