fix(mongodb): complete $elemMatch with the operators it accepts

This commit is contained in:
Loc Nguyen
2026-10-04 16:45:13 +08:00
committed by GitHub
parent b66d7313f1
commit ee2573f7db
3 changed files with 51 additions and 5 deletions
@@ -6,6 +6,7 @@ import {
ENUM_VALUES,
EXTENDED_JSON_VALUES,
FIELD_QUERY_OPERATORS,
ELEM_MATCH_QUERY_OPERATORS,
PIPELINE_STAGES,
PUSH_MODIFIERS,
BULK_WRITE_OPERATION_FIELDS,
@@ -45,6 +46,7 @@ export type MongoCompletionMode =
| "field"
| "filterField"
| "pullCondition"
| "elemMatchKey"
| "fieldPath"
| "fieldRef"
| "indexName"
@@ -496,6 +498,10 @@ export function buildMongoCompletionItemsFromContext(context: MongoCompletionCon
// Fields lead; query operators follow once `$` is typed.
items = [...fieldItems(prefix, fields), ...specItems(FIELD_QUERY_OPERATORS, prefix, "query operator", 80)];
break;
case "elemMatchKey":
// Fields lead; field query operators and $and / $or / $nor follow once `$` is typed.
items = [...fieldItems(prefix, fields), ...specItems(ELEM_MATCH_QUERY_OPERATORS, prefix, "query operator", 80)];
break;
case "fieldPath":
items = fieldPathItems(prefix, fields);
break;
@@ -569,7 +575,7 @@ export function buildMongoCompletionItemsFromContext(context: MongoCompletionCon
/** Modes whose items are built from the target collection's sampled fields. */
export function mongoCompletionNeedsFields(mode: MongoCompletionMode): boolean {
return mode === "field" || mode === "filterField" || mode === "pullCondition" || mode === "fieldPath" || mode === "fieldRef" || mode === "expression";
return mode === "field" || mode === "filterField" || mode === "pullCondition" || mode === "elemMatchKey" || mode === "fieldPath" || mode === "fieldRef" || mode === "expression";
}
/** Modes whose items are built from the database's collection names. */
@@ -1049,6 +1055,7 @@ function classifyFilter(scan: MongoCallScan, rootIndex: number): MongoCursorClas
if (enumKey) return { mode: "enumValue", enumKey };
return { mode: scan.inString ? "none" : "value" };
}
if (inner.key === "$elemMatch") return { mode: "elemMatchKey" };
if (innerDepth(scan, rootIndex) === 0) return { mode: "filterField" };
// Inside a nested object: whose value is it?
@@ -1059,8 +1066,6 @@ function classifyFilter(scan: MongoCallScan, rootIndex: number): MongoCursorClas
const parent = scan.stack[scan.stack.length - 2];
return { mode: parent?.kind === "array" && VALUE_ARRAY_OPERATORS.has(parent.key ?? "") ? "valueWrapper" : "filterField" };
}
case "$elemMatch":
return { mode: "filterField" };
case "$expr":
return { mode: "expression" };
default:
@@ -84,6 +84,15 @@ export const TOP_LEVEL_QUERY_OPERATORS: MongoOperatorSpec[] = specs([
["$jsonSchema", "Matches documents against a JSON schema", "$jsonSchema: { ${} }"],
]);
/** Logical operators accepted inside `$elemMatch` and at the root of a filter. */
export const LOGICAL_QUERY_OPERATORS: MongoOperatorSpec[] = TOP_LEVEL_QUERY_OPERATORS.filter((op) => ["$and", "$or", "$nor"].includes(op.label));
/**
* Operators accepted directly inside the body of `$elemMatch`.
* Matches both scalar elements (via field query operators) and document elements (via field names + logical operators).
*/
export const ELEM_MATCH_QUERY_OPERATORS: MongoOperatorSpec[] = [...FIELD_QUERY_OPERATORS, ...LOGICAL_QUERY_OPERATORS];
/** Every query operator, for callers that do not care about position. */
export const QUERY_OPERATORS: MongoOperatorSpec[] = [...FIELD_QUERY_OPERATORS, ...TOP_LEVEL_QUERY_OPERATORS];
+34 -2
View File
@@ -437,8 +437,8 @@ test("offers whole-filter operators at the top level and field operators under a
assert.ok(bare.includes("$or"));
});
test("treats $and / $or sub-filters, $elemMatch bodies and $match as filters", () => {
for (const text of ["db.users.find({ $or: [{ $", "db.users.find({ items: { $elemMatch: { $", "db.users.aggregate([{ $match: { $"]) {
test("treats $and / $or sub-filters and $match as filters", () => {
for (const text of ["db.users.find({ $or: [{ $", "db.users.aggregate([{ $match: { $"]) {
const items = labels(text, { fields });
assert.ok(items.includes("$or"), text);
assert.equal(items.includes("$gte"), false, text);
@@ -447,6 +447,34 @@ test("treats $and / $or sub-filters, $elemMatch bodies and $match as filters", (
assert.ok(labels("db.users.find({ $or: [{ age: { $", { fields }).includes("$gte"));
});
test("completes $elemMatch body with field names, field query operators and logical operators", () => {
const findItems = labels("db.users.find({ tags: { $elemMatch: { $", { fields });
for (const op of ["$gte", "$lt", "$in", "$regex", "$exists", "$and", "$or", "$nor"]) {
assert.ok(findItems.includes(op), `find $elemMatch must include ${op}`);
}
for (const op of ["$expr", "$text", "$where", "$jsonSchema"]) {
assert.equal(findItems.includes(op), false, `find $elemMatch must exclude ${op}`);
}
const bare = labels("db.users.find({ tags: { $elemMatch: { ", { fields });
assert.ok(
bare.slice(0, fields.length).every((label) => !label.startsWith("$")),
`fields first: ${bare.join(", ")}`,
);
assert.deepEqual(bare.slice(0, fields.length), ["_id", "createdAt", "name", "profile.email"]);
assert.ok(labels("db.users.find({ tags: { $elemMatch: { qty: { $", { fields }).includes("$gte"));
assert.ok(labels("db.users.find({ tags: { $elemMatch: { $or: [{ ", { fields }).includes("name"));
const aggItems = labels("db.users.aggregate([{ $match: { tags: { $elemMatch: { $", { fields });
for (const op of ["$gte", "$lt", "$in", "$regex", "$exists", "$and", "$or", "$nor"]) {
assert.ok(aggItems.includes(op), `aggregate $elemMatch must include ${op}`);
}
for (const op of ["$expr", "$text", "$where", "$jsonSchema"]) {
assert.equal(aggItems.includes(op), false, `aggregate $elemMatch must exclude ${op}`);
}
});
test("does not offer filter operators in update or insert documents", () => {
assert.equal(labels("db.users.updateOne({}, { $set: { ", { fields }).includes("$or"), false);
assert.equal(labels("db.users.insertOne({ $", { fields }).length, 0);
@@ -1016,6 +1044,10 @@ test("suggests projection operators in find projections", () => {
assert.deepEqual(labels("db.users.find({}, { tags: { $elemMatch: { ", { fields }).slice(0, 3), ["_id", "createdAt", "name"]);
assert.ok(labels("db.users.find({}, { tags: { $elemMatch: { score: { ", { fields }).includes("$gt"));
assert.ok(labels("db.users.find({}, { tags: { $elemMatch: { score: { $gt: ", { fields }).includes("NumberInt"));
const projElemMatch = labels("db.users.find({}, { tags: { $elemMatch: { $", { fields });
assert.ok(projElemMatch.includes("$gte"));
assert.ok(projElemMatch.includes("$or"));
assert.equal(projElemMatch.includes("$expr"), false);
});
test("every suggested projection operator parses", () => {