feat(completion): complete mongo projection operators

This commit is contained in:
Loc Nguyen authored and GitHub committed 2026-10-02 09:12:42 +08:00
1 parent e0f2072bef
commit 0f81dcdf40
3 files changed
+100 -5

No files matched your search

+42 -5
View File
@@ -12,6 +12,7 @@ import {
BULK_WRITE_OPERATIONS,
KEY_MAP_VALUES,
METHOD_OPTION_KEYS,
PROJECTION_OPERATORS,
OPERATOR_SUB_KEYS,
STAGE_OPTION_KEYS,
TOP_LEVEL_QUERY_OPERATORS,
@@ -51,6 +52,7 @@ export type MongoCompletionMode =
| "queryOperator"
| "updateOperator"
| "pushModifier"
| "projectionOperator"
| "expression"
| "accumulator"
| "windowOperator"
@@ -504,6 +506,9 @@ export function buildMongoCompletionItemsFromContext(context: MongoCompletionCon
case "pushModifier":
items = specItems(PUSH_MODIFIERS, prefix, "array update modifier", 100);
break;
case "projectionOperator":
items = specItems(PROJECTION_OPERATORS, prefix, "projection operator", 100);
break;
case "expression":
items = [...specItems(EXPRESSION_OPERATORS, prefix, "aggregation expression", 100), ...fieldRefItems(prefix, fields, 80)];
break;
@@ -1031,14 +1036,43 @@ function classifyDocument(scan: MongoCallScan, rootIndex: number): MongoCompleti
* Keys are field names; values are the small fixed set `keyMap` names.
*/
function classifyKeyMap(scan: MongoCallScan, rootIndex: number, keyMap: string): MongoCursorClass {
if (keyMap === "projection") return classifyFindProjection(scan, rootIndex);
const inner = innermost(scan);
if (!inner || inner.kind !== "object" || innerDepth(scan, rootIndex) !== 0) return { mode: "none" };
if (scan.inValue) return { mode: scan.inString ? "none" : "keyMapValue", keyMap };
return { mode: "field" };
}
/** Option keys whose value is a field-to-value map, so the cursor completes field names there. */
const FIELD_MAP_OPTION_KEYS = new Set(["sort", "projection"]);
/**
* A find-style projection: `find({}, { ... })`, `findOne({}, { ... })`, or `{ projection: { ... } }`.
* At the root level, keys are field names and values are 1/0. One level in, keys are projection
* operators (`$slice`, `$elemMatch`, `$meta`).
*/
function classifyFindProjection(scan: MongoCallScan, rootIndex: number): MongoCursorClass {
const inner = innermost(scan);
const depth = innerDepth(scan, rootIndex);
if (!inner || depth < 0) return { mode: "none" };
const elemMatchIndex = findContainerIndex(scan, rootIndex, "$elemMatch");
if (elemMatchIndex >= 0) return classifyFilter(scan, elemMatchIndex);
if (depth === 0) {
if (inner.kind !== "object") return { mode: "none" };
if (scan.inValue) return { mode: scan.inString ? "none" : "keyMapValue", keyMap: "projection" };
return { mode: "field" };
}
if (depth === 1) {
if (inner.kind !== "object") return { mode: "none" };
if (scan.inValue) {
if (scan.valueKey === "$meta") return { mode: "enumValue", enumKey: "$meta" };
return { mode: "none" };
}
return { mode: "projectionOperator" };
}
return { mode: "none" };
}
/** Option keys whose value is a fixed set of strings. */
const OPTION_VALUE_ENUMS: Record<string, string> = { returnDocument: "returnDocument", validationLevel: "validationLevel", validationAction: "validationAction" };
@@ -1076,10 +1110,13 @@ function classifyMethodOptions(method: string, scan: MongoCallScan): MongoCursor
// Which option's value holds the cursor, however deep.
const option = scan.stack[1]?.key ?? "";
// `{ sort: { … } }` and `{ projection: { … } }` are field maps one level in.
if (depth === 1 && inner.kind === "object" && FIELD_MAP_OPTION_KEYS.has(option)) {
if (option === "projection") {
return { ...classifyKeyMap(scan, 1, "projection"), method };
}
// `{ sort: { … } }` is a field map one level in.
if (depth === 1 && inner.kind === "object" && option === "sort") {
if (!scan.inValue) return { mode: "field", method };
return { mode: scan.inString ? "none" : "keyMapValue", method, keyMap: option };
return { mode: scan.inString ? "none" : "keyMapValue", method, keyMap: "sort" };
}
if (FILTER_OPTION_KEYS.has(option)) return { ...classifyFilter(scan, 1), method };
if (option === "arrayFilters") return { ...classifyFilter(scan, 2), method };
@@ -16,6 +16,7 @@
* QUERY_OPERATORS → filter documents (`find`, `$match`, …)
* UPDATE_OPERATORS → update documents (`updateOne`, `findOneAndUpdate`, …)
* PUSH_MODIFIERS → the object value of `$push` / `$addToSet`
* PROJECTION_OPERATORS → projection documents (`find`, `findOne`, `findOneAndUpdate`, …)
* PIPELINE_STAGES → elements of an aggregation pipeline array
* ACCUMULATORS → the output fields of `$group`
* EXPRESSION_OPERATORS → aggregation expression position (`$project`, `$expr`, …)
@@ -112,6 +113,13 @@ export const PUSH_MODIFIERS: MongoOperatorSpec[] = specs([
["$position", "Insert position for $each", "$position: 0"],
]);
/** Projection operators, valid inside a projected field's object value in find-style projections. */
export const PROJECTION_OPERATORS: MongoOperatorSpec[] = specs([
["$slice", "Limits the number of elements projected from an array", "$slice: ${}"],
["$elemMatch", "Projects the first array element matching a condition", "$elemMatch: { ${} }"],
["$meta", "Projects metadata associated with the document", '$meta: "textScore"'],
]);
export const PIPELINE_STAGES: MongoOperatorSpec[] = specs([
["$match", "Filters documents", "$match: { ${} }"],
["$project", "Reshapes documents", "$project: { ${} }"],
@@ -494,6 +502,10 @@ export const ENUM_VALUES: Record<string, MongoOperatorSpec[]> = {
["s", "Dot matches newlines", '"s"'],
["u", "Unicode character classes", '"u"'],
]),
$meta: specs([
["textScore", "Access the text search score", '"textScore"'],
["indexKey", "Access the index key for the document", '"indexKey"'],
]),
geometryType: specs([
["Point", "Single position", '"Point"'],
["LineString", "Sequence of positions", '"LineString"'],
+46
View File
@@ -12,6 +12,7 @@ import {
METHOD_OPTION_KEYS,
OPERATOR_SUB_KEYS,
PIPELINE_STAGES,
PROJECTION_OPERATORS,
PUSH_MODIFIERS,
QUERY_OPERATORS,
STAGE_OPTION_KEYS,
@@ -828,6 +829,49 @@ test("every suggested map value parses in the position that offers it", () => {
}
});
test("suggests projection operators in find projections", () => {
const ops = ["$elemMatch", "$meta", "$slice"];
assert.deepEqual(labels("db.users.find({}, { tags: { ", { fields }), ops);
assert.deepEqual(labels("db.users.find({}, { tags: { $", { fields }), ops);
assert.deepEqual(labels("db.users.findOne({}, { tags: { ", { fields }), ops);
assert.deepEqual(labels("db.users.findOneAndUpdate({}, { $set: { a: 1 } }, { projection: { tags: { ", { fields }), ops);
assert.deepEqual(labels("db.users.findOneAndDelete({}, { projection: { tags: { ", { fields }), ops);
// Positional operator form needs nothing new and still completes map values
assert.deepEqual(labels('db.users.find({}, { "tags.$": ', { fields }), ["0", "1"]);
// $slice takes only numbers, so completion offers nothing
assert.deepEqual(labels("db.users.find({}, { tags: { $slice: ", { fields }), []);
assert.deepEqual(labels("db.users.find({}, { tags: { $slice: [", { fields }), []);
// $meta value enum in and out of quotes
assert.deepEqual(labels('db.users.find({}, { score: { $meta: "', { fields }), ["textScore", "indexKey"]);
assert.deepEqual(labels('db.users.find({}, { score: { $meta: "text', { fields }), ["textScore"]);
assert.deepEqual(labels("db.users.find({}, { score: { $meta: ", { fields }), ["textScore", "indexKey"]);
const quotedMeta = buildMongoCompletionItems('db.users.find({}, { score: { $meta: "', 'db.users.find({}, { score: { $meta: "'.length);
const unquotedMeta = buildMongoCompletionItems("db.users.find({}, { score: { $meta: ", "db.users.find({}, { score: { $meta: ".length);
assert.equal(quotedMeta.find((item) => item.label === "textScore")?.apply, '"textScore"');
assert.equal(unquotedMeta.find((item) => item.label === "textScore")?.apply, '"textScore"');
assert.equal(getMongoCompletionContext('db.users.find({}, { score: { $meta: "" } })', 'db.users.find({}, { score: { $meta: "'.length).replaceClosingQuote, '"');
// $elemMatch body completes fields and filter operators
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"));
});
test("every suggested projection operator parses", () => {
const render = (op: { label: string; apply: string }) => {
if (op.label === "$elemMatch") return "$elemMatch: { score: 1 }";
return op.apply.replace(/\$\{([^{}]*)\}/g, (_, name: string) => name || "1");
};
for (const op of PROJECTION_OPERATORS) {
const command = `db.users.find({}, { tags: { ${render(op)} } })`;
assert.ok(parseMongoCommand(command), `${command} must parse`);
}
});
test("offers nothing rather than top-level snippets inside an unmodelled argument", () => {
// `db.collection.find` is not something that can be typed inside these parentheses, so the
// top-level snippets are noise there; the engine stays quiet until the argument is modelled.
@@ -1207,6 +1251,7 @@ test("every suggested sub-document key and enumerated value parses in the positi
const valueCommands: Record<string, (value: string) => string> = {
$type: (value) => `db.users.find({ a: { $type: ${value} } })`,
$meta: (value) => `db.users.find({}, { score: { $meta: ${value} } })`,
bsonType: (value) => `db.users.find({ $jsonSchema: { bsonType: ${value} } })`,
$options: (value) => `db.users.find({ a: { $regex: "x", $options: ${value} } })`,
geometryType: (value) => `db.users.find({ loc: { $geoIntersects: { $geometry: { type: ${value}, coordinates: [] } } } })`,
@@ -1385,6 +1430,7 @@ test("snippet templates use placeholder syntax CodeMirror actually honours", ()
...QUERY_OPERATORS,
...UPDATE_OPERATORS,
...PUSH_MODIFIERS,
...PROJECTION_OPERATORS,
...PIPELINE_STAGES,
...ACCUMULATORS,
...WINDOW_OPERATORS,