diff --git a/apps/desktop/src/lib/mongo/mongoCompletion.ts b/apps/desktop/src/lib/mongo/mongoCompletion.ts index f3085a4e02..0c9a7930fd 100644 --- a/apps/desktop/src/lib/mongo/mongoCompletion.ts +++ b/apps/desktop/src/lib/mongo/mongoCompletion.ts @@ -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> = { + $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(); @@ -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> = { }; /** 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> = { 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): 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 }; } /** diff --git a/apps/desktop/src/lib/mongo/mongoCompletionTables.ts b/apps/desktop/src/lib/mongo/mongoCompletionTables.ts index 67775bbec5..35d3adfa6b 100644 --- a/apps/desktop/src/lib/mongo/mongoCompletionTables.ts +++ b/apps/desktop/src/lib/mongo/mongoCompletionTables.ts @@ -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 = { + 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 = { ["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 = { + 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([ diff --git a/packages/app-tests/mongoCompletion.test.ts b/packages/app-tests/mongoCompletion.test.ts index 5c1a706902..43ff1acf46 100644 --- a/packages/app-tests/mongoCompletion.test.ts +++ b/packages/app-tests/mongoCompletion.test.ts @@ -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")); +});