feat(completion): complete mongo projection operators
This commit is contained in:
1 parent
e0f2072bef
commit
0f81dcdf40
3 files changed
+100
-5
No files matched your search
@@ -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
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user