feat(mongo): improve query suggestions for nested operators and options

This commit is contained in:
Loc Nguyen authored and GitHub committed 2026-10-06 21:28:26 +08:00
1 parent 0503d3b9d1
commit c6159213aa
3 files changed
+423 -42

No files matched your search

+134 -41
View File
@@ -1043,6 +1043,11 @@ function collectInnermostObjectKeys(text: string, cursor: number, stack: MongoCo
function isExpressionArray(scan: MongoCallScan): boolean {
const inner = innermost(scan);
if (inner?.kind !== "array") return false;
if (inner.key === "branches") {
// Only `$switch.branches` is a case list; any other `branches` array is a plain expression array.
const parent = scan.stack[scan.stack.length - 2];
if (parent?.kind === "object" && parent.key === "$switch") return false;
}
if (inner.key?.startsWith("$")) return true;
for (let i = scan.stack.length - 1; i >= 0; i--) {
const container = scan.stack[i];
@@ -1052,6 +1057,49 @@ function isExpressionArray(scan: MongoCallScan): boolean {
return false;
}
const EXPRESSION_OPERATOR_VALUE_ENUMS: Record<string, Record<string, string>> = {
$regexMatch: { options: "$options" },
$regexFind: { options: "$options" },
$regexFindAll: { options: "$options" },
$dateToParts: { iso8601: "boolean" },
};
function isSwitchBranch(scan: MongoCallScan): boolean {
const n = scan.stack.length;
if (n < 3) return false;
const inner = scan.stack[n - 1];
const parent = scan.stack[n - 2];
const grandParent = scan.stack[n - 3];
return inner?.kind === "object" && parent?.kind === "array" && parent.key === "branches" && grandParent?.kind === "object" && grandParent.key === "$switch";
}
function classifyExpressionCursor(scan: MongoCallScan, rootKey?: string): MongoCursorClass {
if (isSwitchBranch(scan)) {
if (scan.inValue) return { mode: "fieldRef" };
return { mode: "operatorField", operator: "switchBranch" };
}
if (isExpressionArray(scan)) return { mode: "fieldRef" };
const inner = innermost(scan);
if (!inner) return { mode: "none" };
if (scan.inValue) {
const parentOp = inner.key ?? "";
const enumKey = EXPRESSION_OPERATOR_VALUE_ENUMS[parentOp]?.[scan.valueKey ?? ""];
if (enumKey) return { mode: "enumValue", enumKey };
return { mode: "fieldRef" };
}
if (inner.kind !== "object") return { mode: "none" };
if (inner.key && inner.key !== rootKey && (inner.key in OPERATOR_SUB_KEYS || inner.key.startsWith("$"))) {
return { mode: "operatorField", operator: inner.key };
}
return { mode: "expression" };
}
function collectScopeVariables(scan: MongoCallScan): string[] {
const vars = new Set<string>();
@@ -1172,10 +1220,7 @@ function classifyFilter(scan: MongoCallScan, rootIndex: number): MongoCursorClas
// Under `$expr` the vocabulary is aggregation expressions.
const exprIndex = findContainerIndex(scan, rootIndex, "$expr");
if (exprIndex >= 0) {
if (isExpressionArray(scan)) return { mode: "fieldRef" };
if (scan.inValue) return { mode: "fieldRef" };
if (inner.kind === "object") return { mode: "expression" };
return { mode: "none" };
return classifyExpressionCursor(scan, "$expr");
}
if (inner.kind === "array") {
@@ -1352,13 +1397,14 @@ const OPTION_COLLECTION_KEYS: Record<string, ReadonlySet<string>> = {
};
/** Option keys whose value is a filter document. */
const FILTER_OPTION_KEYS = new Set(["partialFilterExpression", "validator"]);
const FILTER_OPTION_KEYS = new Set(["partialFilterExpression", "validator", "filter", "query", "updates", "deletes"]);
/** Option keys whose value is a document with its own fixed keys, and the value sets inside it. */
const SUB_DOCUMENT_OPTION_KEYS: Record<string, Record<string, string>> = {
collation: { locale: "locale", caseFirst: "caseFirst", alternate: "alternate", maxVariable: "maxVariable", strength: "strength" },
timeseries: { granularity: "granularity" },
clusteredIndex: {},
roles: { role: "builtInRole" },
};
function classifyMethodOptions(method: string, scan: MongoCallScan): MongoCursorClass {
@@ -1405,8 +1451,15 @@ function classifyCollation(scan: MongoCallScan, rootIndex: number): MongoCursorC
/** A one-level document with a fixed key set (`OPERATOR_SUB_KEYS[operator]`) and, for some keys, a fixed value set. */
function classifySubDocument(scan: MongoCallScan, rootIndex: number, operator: string, valueEnums: Record<string, string>): MongoCursorClass {
const inner = innermost(scan);
if (!inner || inner.kind !== "object" || innerDepth(scan, rootIndex) !== 0) return { mode: "none" };
const depth = innerDepth(scan, rootIndex);
if (!inner || inner.kind !== "object") return { mode: "none" };
// Allow depth 0 (direct object) or depth 1 (array of objects)
if (depth > 1) return { mode: "none" };
if (depth === 1 && scan.stack[scan.stack.length - 2]?.kind !== "array") return { mode: "none" };
if (scan.inValue) {
if (operator === "roles" && scan.valueKey === "db") return { mode: "database" };
const enumKey = valueEnums[scan.valueKey ?? ""];
return enumKey ? { mode: "enumValue", enumKey } : { mode: "none" };
}
@@ -1540,43 +1593,46 @@ function stageStringValueMode(stage: string): MongoCompletionMode {
function classifyStageBody(stage: string, scan: MongoCallScan, bodyIndex: number): MongoCursorClass {
if (stage === "$match") return { ...classifyFilter(scan, bodyIndex), stage };
if (stage === "$group") return { mode: classifyGroup(scan, bodyIndex), stage };
if (stage === "$group") return { ...classifyGroup(scan, bodyIndex), stage };
if (stage === "$sort") return { ...classifyKeyMap(scan, bodyIndex, "sort"), stage };
if (stage === "$unset") return { mode: innermost(scan)?.kind === "array" ? "fieldPath" : "none", stage };
if (OPTION_STAGES.has(stage)) return classifyStageOptions(stage, scan, bodyIndex);
// `$project`-shaped stages and anything unmodelled: keys are field names, values are expressions.
return { mode: classifyProjection(scan, bodyIndex), stage };
return { ...classifyProjection(scan, bodyIndex), stage };
}
function classifyGroup(scan: MongoCallScan, bodyIndex: number): MongoCompletionMode {
function classifyGroup(scan: MongoCallScan, bodyIndex: number): MongoCursorClass {
const depth = innerDepth(scan, bodyIndex);
if (depth < 0) return "none";
if (depth < 0) return { mode: "none" };
if (depth === 0) {
// `{ $group: { _id: … , total: … } }` — keys are output names, `_id` is required.
if (!scan.inValue) return "field";
return scan.valueKey === "_id" ? "fieldRef" : "none";
if (scan.inValue) return { mode: "fieldRef" };
return { mode: innermost(scan)?.kind === "object" ? "field" : "none" };
}
if (scan.inValue) return "fieldRef";
const inner = innermost(scan);
if (isExpressionArray(scan)) return "fieldRef";
if (inner?.kind !== "object") return "none";
// One level in: `_id: { … }` builds a compound key, anything else is an accumulator.
if (depth === 1) return inner.key === "_id" ? "expression" : "accumulator";
return "expression";
if (depth === 1) {
const inner = innermost(scan);
if (inner?.kind === "array" && isExpressionArray(scan)) return classifyExpressionCursor(scan);
if (inner?.kind !== "object") return { mode: "none" };
if (scan.inValue) return { mode: "fieldRef" };
if (inner.key === "_id") return { mode: "expression" };
return { mode: "accumulator" };
}
return classifyExpressionCursor(scan);
}
function classifyProjection(scan: MongoCallScan, bodyIndex: number): MongoCompletionMode {
function classifyProjection(scan: MongoCallScan, bodyIndex: number): MongoCursorClass {
const depth = innerDepth(scan, bodyIndex);
if (depth < 0) return "none";
if (scan.inValue) return "fieldRef";
if (depth < 0) return { mode: "none" };
const inner = innermost(scan);
if (isExpressionArray(scan)) return "fieldRef";
if (inner?.kind !== "object") return "none";
return depth === 0 ? "field" : "expression";
if (depth === 0) {
if (scan.inValue) return { mode: "fieldRef" };
return { mode: innermost(scan)?.kind === "object" ? "field" : "none" };
}
return classifyExpressionCursor(scan);
}
function classifyStageOptions(stage: string, scan: MongoCallScan, bodyIndex: number): MongoCursorClass {
@@ -1628,9 +1684,7 @@ function classifyStageOptions(stage: string, scan: MongoCallScan, bodyIndex: num
if (!scan.inValue) return { mode: "windowOperator", stage };
return { mode: "fieldRef", stage };
}
if (scan.inValue) return { mode: "fieldRef", stage };
if (isExpressionArray(scan)) return { mode: "fieldRef", stage };
return { mode: innermost(scan)?.kind === "object" ? "expression" : "none", stage };
return { ...classifyExpressionCursor(scan), stage };
}
if (stage === "$fill") {
@@ -1662,9 +1716,7 @@ function classifyStageOptions(stage: string, scan: MongoCallScan, bodyIndex: num
if (innermost(scan)?.kind === "array") return { mode: "fieldPath", stage };
}
if (scan.inValue) return { mode: "fieldRef", stage };
if (isExpressionArray(scan)) return { mode: "fieldRef", stage };
return { mode: innermost(scan)?.kind === "object" ? "expression" : "none", stage };
return { ...classifyExpressionCursor(scan), stage };
}
/* ------------------------------------------------------------------ *
@@ -2148,20 +2200,61 @@ function matchCursorMethodDot(beforeCursor: string): { find: boolean; countable:
const chain = beforeCursor.slice(closeParen + 1);
if (!/\.\s*[\w$-]*$/.test(chain)) return null;
let pos = closeParen + 1;
let isCountable = true;
let isTerminal = false;
const isFind = lastMatch[1] === "find";
if (/\.\s*(?:count|explain)\s*\([^()]*\)/.test(chain)) {
const validMethods = isFind ? ["sort", "skip", "limit", "collation", "toArray", "pretty", "hint", "batchSize", "maxTimeMS", "comment"] : ["toArray", "pretty"];
while (pos < beforeCursor.length) {
const nextDot = beforeCursor.indexOf(".", pos);
if (nextDot === -1) break;
const afterDot = beforeCursor.slice(nextDot + 1);
const methodMatch = afterDot.match(/^\s*([\w$]+)\s*\(/);
// If there's no next method call but there is a dot, we are typing the next method name.
if (!methodMatch) {
if (/^\s*[\w$-]*$/.test(afterDot)) {
// Typing the method.
break;
} else {
// Something else, invalid chain.
return null;
}
}
const methodName = methodMatch[1];
const methodOpenParen = nextDot + 1 + methodMatch[0].length - 1;
const methodCloseParen = findMatchingParen(beforeCursor, methodOpenParen);
if (methodName === "count" || methodName === "explain") {
isTerminal = true;
} else if (!validMethods.includes(methodName)) {
return null;
} else {
// Any method chained between find() and the cursor breaks the bare find() count() parses on.
isCountable = false;
}
if (methodCloseParen === -1) {
// Incomplete method arguments, so we aren't typing a method dot
return null;
}
pos = methodCloseParen + 1;
}
if (isTerminal) {
return { find: isFind, countable: false, terminal: true };
}
if (isFind) {
if (!/^(?:\s*\.\s*(?:sort|skip|limit|collation|toArray|pretty)\s*\([^()]*\))*\s*\.\s*[\w$-]*$/.test(chain)) return null;
return { find: true, countable: /^\s*\.\s*[\w$-]*$/.test(chain) };
if (!isFind) {
return { find: false, countable: false };
}
if (!/^(?:\s*\.\s*(?:toArray|pretty)\s*\([^()]*\))*\s*\.\s*[\w$-]*$/.test(chain)) {
return { find: false, countable: false, terminal: true };
}
return { find: false, countable: false };
// Countable only while no method call has been walked since find().
return { find: true, countable: isCountable };
}
/**
@@ -56,6 +56,7 @@ export const FIELD_QUERY_OPERATORS: MongoOperatorSpec[] = specs([
["$exists", "Matches documents that have the field", "$exists: true"],
["$type", "Matches documents by BSON type", '$type: "${string}"'],
["$regex", "Matches a regular expression", '$regex: "${pattern}"'],
["$options", "Regular expression options", '$options: "${i}"'],
["$mod", "Matches values by modulo division", "$mod: [${divisor}, ${remainder}]"],
["$all", "Matches arrays containing all the values", "$all: [${}]"],
["$elemMatch", "Matches arrays with an element matching all criteria", "$elemMatch: { ${} }"],
@@ -329,6 +330,11 @@ export const EXPRESSION_OPERATORS: MongoOperatorSpec[] = specs([
["$isoWeek", "ISO week number of a date", '$isoWeek: "$${field}"'],
["$isoWeekYear", "ISO week-numbering year of a date", '$isoWeekYear: "$${field}"'],
["$isoDayOfWeek", "ISO day of the week of a date", '$isoDayOfWeek: "$${field}"'],
// Bitwise
["$bitAnd", "Returns the bitwise AND of an array of numbers", "$bitAnd: [${}]"],
["$bitOr", "Returns the bitwise OR of an array of numbers", "$bitOr: [${}]"],
["$bitXor", "Returns the bitwise XOR of an array of numbers", "$bitXor: [${}]"],
["$bitNot", "Returns the bitwise NOT of a number", "$bitNot: ${}"],
// Types and misc
["$type", "Returns the BSON type of a value", "$type: ${}"],
["$convert", "Converts a value to a given type", '$convert: { input: ${}, to: "${string}" }'],
@@ -426,6 +432,10 @@ const JSON_SCHEMA_KEYWORDS: Spec[] = [
* object of the update operator.
*/
export const OPERATOR_SUB_KEYS: Record<string, MongoOperatorSpec[]> = {
roles: specs([
["role", "Name of the role", 'role: "${read}"'],
["db", "Database on which the role is granted", 'db: "${admin}"'],
]),
$text: specs([
["$search", "Words or phrases to search for", '$search: "${text}"'],
["$language", "Language for stemming and stop words", '$language: "${en}"'],
@@ -480,6 +490,96 @@ export const OPERATOR_SUB_KEYS: Record<string, MongoOperatorSpec[]> = {
["value", "Value or expression to fill with", "value: ${}"],
["method", "Fill method", 'method: "${linear}"'],
]),
$dateToString: specs([
["date", "Date to format", 'date: "$${field}"'],
["format", "Format specification string", 'format: "${%Y-%m-%d}"'],
["timezone", "Timezone for formatting", 'timezone: "${UTC}"'],
["onNull", "Value to return if date is null or missing", "onNull: ${}"],
]),
$dateFromParts: specs([
["year", "Calendar year", "year: ${}"],
["month", "Month of year (1-12)", "month: ${}"],
["day", "Day of month (1-31)", "day: ${}"],
["hour", "Hour of day (0-23)", "hour: ${}"],
["minute", "Minute of hour (0-59)", "minute: ${}"],
["second", "Second of minute (0-59)", "second: ${}"],
["millisecond", "Millisecond of second (0-999)", "millisecond: ${}"],
["timezone", "Timezone for formatting", 'timezone: "${UTC}"'],
["isoWeekYear", "ISO week-numbering year", "isoWeekYear: ${}"],
["isoWeek", "ISO week number (1-53)", "isoWeek: ${}"],
["isoDayOfWeek", "Day of week (1-7, Monday is 1)", "isoDayOfWeek: ${}"],
]),
$dateToParts: specs([
["date", "Date to convert to parts", 'date: "$${field}"'],
["timezone", "Timezone for calculation", 'timezone: "${UTC}"'],
["iso8601", "Whether to return ISO week date parts", "iso8601: true"],
]),
$trim: specs([
["input", "String to trim", 'input: "$${field}"'],
["chars", "Characters to trim", 'chars: "${ }"'],
]),
$ltrim: specs([
["input", "String to trim", 'input: "$${field}"'],
["chars", "Characters to trim", 'chars: "${ }"'],
]),
$rtrim: specs([
["input", "String to trim", 'input: "$${field}"'],
["chars", "Characters to trim", 'chars: "${ }"'],
]),
$replaceOne: specs([
["input", "String to search in", 'input: "$${field}"'],
["find", "String or regex to search for", 'find: "${find}"'],
["replacement", "String to replace with", 'replacement: "${replacement}"'],
]),
$replaceAll: specs([
["input", "String to search in", 'input: "$${field}"'],
["find", "String or regex to search for", 'find: "${find}"'],
["replacement", "String to replace with", 'replacement: "${replacement}"'],
]),
$regexMatch: specs([
["input", "String to evaluate", 'input: "$${field}"'],
["regex", "Regular expression to match", 'regex: "${pattern}"'],
["options", "Regular expression options", 'options: "${i}"'],
]),
$regexFind: specs([
["input", "String to evaluate", 'input: "$${field}"'],
["regex", "Regular expression to match", 'regex: "${pattern}"'],
["options", "Regular expression options", 'options: "${i}"'],
]),
$regexFindAll: specs([
["input", "String to evaluate", 'input: "$${field}"'],
["regex", "Regular expression to match", 'regex: "${pattern}"'],
["options", "Regular expression options", 'options: "${i}"'],
]),
$filter: specs([
["input", "Array expression to filter", 'input: "$${field}"'],
["as", "Variable name for each element", 'as: "${item}"'],
["cond", "Boolean expression for each element", "cond: ${}"],
["limit", "Maximum number of elements to return", "limit: ${}"],
]),
$map: specs([
["input", "Array expression to transform", 'input: "$${field}"'],
["as", "Variable name for each element", 'as: "${item}"'],
["in", "Expression applied to each element", "in: ${}"],
]),
$reduce: specs([
["input", "Array expression to reduce", 'input: "$${field}"'],
["initialValue", "Initial value of the accumulator", "initialValue: ${}"],
["in", "Expression applied to each element", "in: ${}"],
]),
$cond: specs([
["if", "Condition to evaluate", "if: ${}"],
["then", "Result if condition is true", "then: ${}"],
["else", "Result if condition is false", "else: ${}"],
]),
$switch: specs([
["branches", "Array of case-then branch documents", "branches: [{ case: ${}, then: ${} }]"],
["default", "Default expression if no branch matches", "default: ${}"],
]),
switchBranch: specs([
["case", "Condition expression to evaluate", "case: ${}"],
["then", "Result expression when case is true", "then: ${}"],
]),
};
const BSON_TYPE_ALIASES: Spec[] = [
@@ -509,6 +609,24 @@ const BSON_TYPE_ALIASES: Spec[] = [
* gets a well-formed literal; numbers do not.
*/
export const ENUM_VALUES: Record<string, MongoOperatorSpec[]> = {
builtInRole: specs([
["read", "Provides reading data on all non-system collections", '"read"'],
["readWrite", "Provides all the privileges of the read role plus ability to modify data", '"readWrite"'],
["dbAdmin", "Provides administrative tasks such as schema-related tasks", '"dbAdmin"'],
["dbOwner", "Provides all privileges of readWrite, dbAdmin, and userAdmin", '"dbOwner"'],
["userAdmin", "Provides ability to create and modify roles and users on the current database", '"userAdmin"'],
["clusterAdmin", "Provides highest cluster-level administrative privileges", '"clusterAdmin"'],
["clusterManager", "Provides management and monitoring actions", '"clusterManager"'],
["clusterMonitor", "Provides read-only access to monitoring tools", '"clusterMonitor"'],
["hostManager", "Provides ability to monitor and manage servers", '"hostManager"'],
["backup", "Provides privileges needed to back up data", '"backup"'],
["restore", "Provides privileges needed to restore data", '"restore"'],
["readAnyDatabase", "Provides the same privileges as read on all databases except local and config", '"readAnyDatabase"'],
["readWriteAnyDatabase", "Provides the same privileges as readWrite on all databases except local and config", '"readWriteAnyDatabase"'],
["userAdminAnyDatabase", "Provides the same privileges as userAdmin on all databases except local and config", '"userAdminAnyDatabase"'],
["dbAdminAnyDatabase", "Provides the same privileges as dbAdmin on all databases except local and config", '"dbAdminAnyDatabase"'],
["root", "Provides access to the operations and all the resources of the readWriteAnyDatabase, dbAdminAnyDatabase, userAdminAnyDatabase, clusterAdmin, restore, and backup combined", '"root"'],
]),
$type: specs(BSON_TYPE_ALIASES),
bsonType: specs(BSON_TYPE_ALIASES),
$options: specs([
+171 -1
View File
@@ -237,6 +237,23 @@ test("recognises toArray and pretty in find chains and keeps count restricted",
assert.deepEqual(labels("db.characters.find({}).sort({ name: 1 }).toArray()."), expectedChainMethods);
});
test("matches cursor chains across nested parentheses in arguments", () => {
const expectedChainMethods = ["limit", "sort", "skip", "explain", "collation", "toArray", "pretty"];
assert.deepEqual(labels("db.characters.find({}).sort({ x: Math.max(1, 2) })."), expectedChainMethods);
assert.deepEqual(labels("db.characters.find({ ratio: Math.min(a, b) }).limit(Math.max(1, 5))."), expectedChainMethods);
});
test("keeps cursor method completion anchored to a dot in the chain", () => {
// A completed call without a trailing dot is not a method-dot position.
assert.equal(labels("db.characters.find()").includes("limit"), false);
assert.equal(labels("db.characters.find({});").includes("limit"), false);
assert.equal(labels("db.characters.find() show col").includes("limit"), false);
});
test("completes field refs inside compound $group _id arrays", () => {
assert.ok(labels('db.users.aggregate([{ $group: { _id: [ "$', { fields }).includes("$name"));
});
test("stops offering root snippets after terminal cursor methods count and explain", () => {
// Nothing can follow count() or explain(); these positions must yield mode "none" rather than root db.* snippets.
assert.equal(getMongoCompletionContext("db.users.find({}).count().", "db.users.find({}).count().".length).mode, "none");
@@ -426,7 +443,7 @@ test("offers whole-filter operators at the top level and field operators under a
assert.ok(under.includes("$oid"));
assert.equal(under.includes("$or"), false, "$or is not valid under a field");
assert.equal(under.includes("$and"), false);
assert.deepEqual(labels("db.users.find({ _id: { $o", { fields }), ["$oid"]);
assert.deepEqual(labels("db.users.find({ _id: { $o", { fields }), ["$options", "$oid"]);
// Fields lead when nothing has been typed yet.
const bare = labels("db.users.find({ ", { fields });
@@ -1537,6 +1554,24 @@ test("every suggested sub-document key and enumerated value parses in the positi
clusteredIndex: (body) => `db.createCollection("x", { clusteredIndex: { ${body} } })`,
range: (body) => `db.users.aggregate([{ $densify: { field: "t", range: { ${body} } } }])`,
fillOutput: (body) => `db.users.aggregate([{ $fill: { output: { score: { ${body} } } } }])`,
roles: (body) => `db.createUser({ user: "x", pwd: "y", roles: [ { ${body} } ] })`,
$dateToString: (body) => `db.users.aggregate([{ $project: { d: { $dateToString: { ${body} } } } }])`,
$dateFromParts: (body) => `db.users.aggregate([{ $project: { d: { $dateFromParts: { ${body} } } } }])`,
$dateToParts: (body) => `db.users.aggregate([{ $project: { d: { $dateToParts: { ${body} } } } }])`,
$trim: (body) => `db.users.aggregate([{ $project: { d: { $trim: { ${body} } } } }])`,
$ltrim: (body) => `db.users.aggregate([{ $project: { d: { $ltrim: { ${body} } } } }])`,
$rtrim: (body) => `db.users.aggregate([{ $project: { d: { $rtrim: { ${body} } } } }])`,
$replaceOne: (body) => `db.users.aggregate([{ $project: { d: { $replaceOne: { ${body} } } } }])`,
$replaceAll: (body) => `db.users.aggregate([{ $project: { d: { $replaceAll: { ${body} } } } }])`,
$regexMatch: (body) => `db.users.aggregate([{ $project: { d: { $regexMatch: { ${body} } } } }])`,
$regexFind: (body) => `db.users.aggregate([{ $project: { d: { $regexFind: { ${body} } } } }])`,
$regexFindAll: (body) => `db.users.aggregate([{ $project: { d: { $regexFindAll: { ${body} } } } }])`,
$filter: (body) => `db.users.aggregate([{ $project: { d: { $filter: { ${body} } } } }])`,
$map: (body) => `db.users.aggregate([{ $project: { d: { $map: { ${body} } } } }])`,
$reduce: (body) => `db.users.aggregate([{ $project: { d: { $reduce: { ${body} } } } }])`,
$cond: (body) => `db.users.aggregate([{ $project: { d: { $cond: { ${body} } } } }])`,
$switch: (body) => `db.users.aggregate([{ $project: { d: { $switch: { ${body} } } } }])`,
switchBranch: (body) => `db.users.aggregate([{ $project: { d: { $switch: { branches: [ { ${body} } ] } } } }])`,
};
for (const [operator, keys] of Object.entries(OPERATOR_SUB_KEYS)) {
const build = keyCommands[operator];
@@ -1572,6 +1607,7 @@ test("every suggested sub-document key and enumerated value parses in the positi
locale: (value) => `db.users.find({}).collation({ locale: ${value} })`,
whenMatched: (value) => `db.users.aggregate([{ $merge: { into: "out", whenMatched: ${value} } }])`,
whenNotMatched: (value) => `db.users.aggregate([{ $merge: { into: "out", whenNotMatched: ${value} } }])`,
builtInRole: (value) => `db.createUser({ user: "x", pwd: "y", roles: [ { role: ${value} } ] })`,
};
for (const [enumKey, values] of Object.entries(ENUM_VALUES)) {
const build = valueCommands[enumKey];
@@ -2415,3 +2451,137 @@ test("preserves auto-trigger and completion for getCollection and getSiblingDB d
assert.equal(getMongoCompletionContext(siblingGetCollectionDot, siblingGetCollectionDot.length).mode, "method");
assert.equal(shouldAutoOpenMongoCompletion(siblingGetCollectionDot, siblingGetCollectionDot.length), true);
});
test("completes expression operator sub-document keys and enum values", () => {
// Sub-keys for $dateToString
const dateToStringKeys = labels("db.users.aggregate([{ $project: { d: { $dateToString: { ");
assert.ok(dateToStringKeys.includes("date"));
assert.ok(dateToStringKeys.includes("format"));
assert.ok(dateToStringKeys.includes("timezone"));
assert.ok(dateToStringKeys.includes("onNull"));
// Sub-keys for $dateFromParts
const dateFromPartsKeys = labels("db.users.aggregate([{ $project: { d: { $dateFromParts: { ");
assert.ok(dateFromPartsKeys.includes("year"));
assert.ok(dateFromPartsKeys.includes("month"));
assert.ok(dateFromPartsKeys.includes("day"));
assert.ok(dateFromPartsKeys.includes("timezone"));
// Sub-keys for $filter
const filterKeys = labels("db.users.aggregate([{ $project: { d: { $filter: { ");
assert.ok(filterKeys.includes("input"));
assert.ok(filterKeys.includes("as"));
assert.ok(filterKeys.includes("cond"));
assert.ok(filterKeys.includes("limit"));
// Sub-keys for $cond
const condKeys = labels("db.users.aggregate([{ $project: { d: { $cond: { ");
assert.ok(condKeys.includes("if"));
assert.ok(condKeys.includes("then"));
assert.ok(condKeys.includes("else"));
// Sub-keys for $switch
const switchKeys = labels("db.users.aggregate([{ $project: { d: { $switch: { ");
assert.ok(switchKeys.includes("branches"));
assert.ok(switchKeys.includes("default"));
// Sub-keys for $regexMatch
const regexMatchKeys = labels("db.users.aggregate([{ $project: { d: { $regexMatch: { ");
assert.ok(regexMatchKeys.includes("input"));
assert.ok(regexMatchKeys.includes("regex"));
assert.ok(regexMatchKeys.includes("options"));
// Enum values for options ($regexMatch, $regexFind, $regexFindAll)
const regexOptions = labels("db.users.aggregate([{ $project: { d: { $regexMatch: { options: ");
assert.ok(regexOptions.includes("i"));
assert.ok(regexOptions.includes("m"));
assert.ok(regexOptions.includes("x"));
assert.ok(regexOptions.includes("s"));
assert.ok(regexOptions.includes("u"));
const regexOptionsQuoted = labels('db.users.aggregate([{ $project: { d: { $regexMatch: { options: "');
assert.ok(regexOptionsQuoted.includes("i"));
// Enum values for iso8601 ($dateToParts)
const dateToPartsIso = labels("db.users.aggregate([{ $project: { d: { $dateToParts: { iso8601: ");
assert.ok(dateToPartsIso.includes("true"));
assert.ok(dateToPartsIso.includes("false"));
// Used key deduplication inside operator sub-documents
const dedupedKeys = labels("db.users.aggregate([{ $project: { d: { $dateToString: { date: '$created', ");
assert.equal(dedupedKeys.includes("date"), false, "already specified key must not be re-suggested");
assert.ok(dedupedKeys.includes("format"));
assert.ok(dedupedKeys.includes("timezone"));
assert.ok(dedupedKeys.includes("onNull"));
});
test("completes $switch branches array elements and fields", () => {
// Empty branch object inside branches array suggests switchBranch keys: case and then
const branchKeys = labels("db.users.aggregate([{ $project: { d: { $switch: { branches: [ { ");
assert.ok(branchKeys.includes("case"));
assert.ok(branchKeys.includes("then"));
// After specifying case, used key deduplication leaves then
const branchAfterCase = labels("db.users.aggregate([{ $project: { d: { $switch: { branches: [ { case: 1, ");
assert.equal(branchAfterCase.includes("case"), false);
assert.ok(branchAfterCase.includes("then"));
// Context mode verification
const branchCtx = getMongoCompletionContext("db.users.aggregate([{ $project: { d: { $switch: { branches: [ { ", "db.users.aggregate([{ $project: { d: { $switch: { branches: [ { ".length);
assert.equal(branchCtx.mode, "operatorField");
assert.equal(branchCtx.operator, "switchBranch");
// In value position of case:, fieldRef mode is used
const caseValCtx = getMongoCompletionContext("db.users.aggregate([{ $project: { d: { $switch: { branches: [ { case: ", "db.users.aggregate([{ $project: { d: { $switch: { branches: [ { case: ".length);
assert.equal(caseValCtx.mode, "fieldRef");
});
test("completes expression operator sub-keys and enums inside $expr in find() and $match", () => {
// find() with $expr -> $dateToString keys
const findExprDate = labels("db.users.find({ $expr: { $dateToString: { ");
assert.ok(findExprDate.includes("date"));
assert.ok(findExprDate.includes("format"));
assert.ok(findExprDate.includes("timezone"));
assert.ok(findExprDate.includes("onNull"));
// find() with $expr -> $regexMatch options enum
const findExprRegexOptions = labels("db.users.find({ $expr: { $regexMatch: { options: ");
assert.ok(findExprRegexOptions.includes("i"));
assert.ok(findExprRegexOptions.includes("m"));
// find() with $expr -> $dateToParts iso8601 enum
const findExprDatePartsIso = labels("db.users.find({ $expr: { $dateToParts: { iso8601: ");
assert.ok(findExprDatePartsIso.includes("true"));
assert.ok(findExprDatePartsIso.includes("false"));
// find() with $expr -> $switch branches
const findExprSwitchBranch = labels("db.users.find({ $expr: { $switch: { branches: [ { ");
assert.ok(findExprSwitchBranch.includes("case"));
assert.ok(findExprSwitchBranch.includes("then"));
// $match with $expr -> $dateToString keys
const matchExprDate = labels("db.users.aggregate([{ $match: { $expr: { $dateToString: { ");
assert.ok(matchExprDate.includes("date"));
assert.ok(matchExprDate.includes("format"));
// $match with $expr -> $regexMatch options enum
const matchExprRegexOptions = labels("db.users.aggregate([{ $match: { $expr: { $regexMatch: { options: ");
assert.ok(matchExprRegexOptions.includes("i"));
assert.ok(matchExprRegexOptions.includes("m"));
});
test("completes expression operator sub-keys and enums in $group", () => {
// $group _id compound key with $dateToString
const groupIdDate = labels("db.users.aggregate([{ $group: { _id: { day: { $dateToString: { ");
assert.ok(groupIdDate.includes("date"));
assert.ok(groupIdDate.includes("format"));
// $group accumulator with expression sub-keys and enums
const groupAccumDate = labels("db.users.aggregate([{ $group: { total: { $sum: { $dateToString: { ");
assert.ok(groupAccumDate.includes("date"));
assert.ok(groupAccumDate.includes("format"));
const groupAccumRegexOpt = labels("db.users.aggregate([{ $group: { total: { $sum: { $regexMatch: { options: ");
assert.ok(groupAccumRegexOpt.includes("i"));
assert.ok(groupAccumRegexOpt.includes("m"));
});