diff --git a/apps/desktop/src/lib/mongo/mongoCompletion.ts b/apps/desktop/src/lib/mongo/mongoCompletion.ts index d4f53d4709..0d4f2f1fa4 100644 --- a/apps/desktop/src/lib/mongo/mongoCompletion.ts +++ b/apps/desktop/src/lib/mongo/mongoCompletion.ts @@ -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 = { 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 }; diff --git a/apps/desktop/src/lib/mongo/mongoCompletionTables.ts b/apps/desktop/src/lib/mongo/mongoCompletionTables.ts index 5b05652e62..23e137fc35 100644 --- a/apps/desktop/src/lib/mongo/mongoCompletionTables.ts +++ b/apps/desktop/src/lib/mongo/mongoCompletionTables.ts @@ -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 = { ["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"'], diff --git a/packages/app-tests/mongoCompletion.test.ts b/packages/app-tests/mongoCompletion.test.ts index a334bbd132..4f9a415dbc 100644 --- a/packages/app-tests/mongoCompletion.test.ts +++ b/packages/app-tests/mongoCompletion.test.ts @@ -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> = { $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,