feat(mongo): improve query suggestions for nested operators and options
This commit is contained in:
1 parent
0503d3b9d1
commit
c6159213aa
3 files changed
+423
-42
No files matched your search
@@ -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
@@ -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"));
|
||||
});
|
||||
Reference in new issue
Block a user