From 608633edffb9ded0340301fc9a92d750ceae12c8 Mon Sep 17 00:00:00 2001 From: Loc Nguyen <113083148+thailoc-dev@users.noreply.github.com> Date: Mon, 14 Sep 2026 16:53:17 +0700 Subject: [PATCH] fix(mongodb): offer query operators by filter position --- apps/desktop/src/lib/mongo/mongoCompletion.ts | 18 +++++--- .../src/lib/mongo/mongoCompletionTables.ts | 30 +++++++++---- packages/app-tests/mongoCompletion.test.ts | 45 ++++++++++++++++++- 3 files changed, 77 insertions(+), 16 deletions(-) diff --git a/apps/desktop/src/lib/mongo/mongoCompletion.ts b/apps/desktop/src/lib/mongo/mongoCompletion.ts index 13c672b3bd..dd9adecf99 100644 --- a/apps/desktop/src/lib/mongo/mongoCompletion.ts +++ b/apps/desktop/src/lib/mongo/mongoCompletion.ts @@ -4,10 +4,11 @@ import { COMMON_OPERATORS, EXPRESSION_OPERATORS, EXTENDED_JSON_VALUES, + FIELD_QUERY_OPERATORS, PIPELINE_STAGES, PUSH_MODIFIERS, - QUERY_OPERATORS, STAGE_OPTION_KEYS, + TOP_LEVEL_QUERY_OPERATORS, UPDATE_OPERATORS, UPDATE_OPERATOR_LABELS, VALUE_SNIPPETS, @@ -34,6 +35,7 @@ export type MongoCompletionMode = | "method" | "cursorMethod" | "field" + | "filterField" | "fieldPath" | "fieldRef" | "value" @@ -310,6 +312,10 @@ export function buildMongoCompletionItemsFromContext(context: MongoCompletionCon case "field": items = fieldItems(prefix, fields); break; + case "filterField": + // Fields lead; `$and` / `$or` and the other whole-filter operators follow once `$` is typed. + items = [...fieldItems(prefix, fields), ...specItems(TOP_LEVEL_QUERY_OPERATORS, prefix, "query operator", 80)]; + break; case "fieldPath": items = fieldPathItems(prefix, fields); break; @@ -325,7 +331,7 @@ export function buildMongoCompletionItemsFromContext(context: MongoCompletionCon break; case "queryOperator": // `{ _id: { $oid: ... } }` is as valid here as `{ _id: { $gt: ... } }`. - items = [...specItems(QUERY_OPERATORS, prefix, "query operator", 100), ...specItems(EXTENDED_JSON_VALUES, prefix, "extended JSON value", 90)]; + items = [...specItems(FIELD_QUERY_OPERATORS, prefix, "query operator", 100), ...specItems(EXTENDED_JSON_VALUES, prefix, "extended JSON value", 90)]; break; case "updateOperator": items = specItems(UPDATE_OPERATORS, prefix, "update operator", 100); @@ -353,7 +359,7 @@ export function buildMongoCompletionItemsFromContext(context: MongoCompletionCon /** Modes whose items are built from the target collection's sampled fields. */ export function mongoCompletionNeedsFields(mode: MongoCompletionMode): boolean { - return mode === "field" || mode === "fieldPath" || mode === "fieldRef" || mode === "expression"; + return mode === "field" || mode === "filterField" || mode === "fieldPath" || mode === "fieldRef" || mode === "expression"; } /** Modes whose items are built from the database's collection names. */ @@ -544,7 +550,7 @@ function classifyFilter(scan: MongoCallScan, rootIndex: number): MongoCompletion if (inner.kind === "array") return VALUE_ARRAY_OPERATORS.has(inner.key ?? "") && !scan.inString ? "value" : "none"; if (inner.kind !== "object") return "none"; if (scan.inValue) return scan.inString ? "none" : "value"; - if (innerDepth(scan, rootIndex) === 0) return "field"; + if (innerDepth(scan, rootIndex) === 0) return "filterField"; // Inside a nested object: whose value is it? switch (inner.key) { @@ -552,10 +558,10 @@ function classifyFilter(scan: MongoCallScan, rootIndex: number): MongoCompletion // An object inside an array: a sub-filter under `$and` / `$or` / `$nor`, // or an extended JSON wrapper such as `{ $oid: ... }` under `$in`. const parent = scan.stack[scan.stack.length - 2]; - return parent?.kind === "array" && VALUE_ARRAY_OPERATORS.has(parent.key ?? "") ? "valueWrapper" : "field"; + return parent?.kind === "array" && VALUE_ARRAY_OPERATORS.has(parent.key ?? "") ? "valueWrapper" : "filterField"; } case "$elemMatch": - return "field"; + return "filterField"; case "$expr": return "expression"; case "$jsonSchema": diff --git a/apps/desktop/src/lib/mongo/mongoCompletionTables.ts b/apps/desktop/src/lib/mongo/mongoCompletionTables.ts index 6bf315e77e..92c4e528ca 100644 --- a/apps/desktop/src/lib/mongo/mongoCompletionTables.ts +++ b/apps/desktop/src/lib/mongo/mongoCompletionTables.ts @@ -38,7 +38,11 @@ export function mongoOperatorItemType(apply: string): "snippet" | "keyword" { return apply.includes("${") ? "snippet" : "keyword"; } -export const QUERY_OPERATORS: MongoOperatorSpec[] = specs([ +/** + * Operators that constrain one field, valid inside `{ field: { ... } }`. + * `$and` / `$or` and friends are not: those go at the top level of a filter. + */ +export const FIELD_QUERY_OPERATORS: MongoOperatorSpec[] = specs([ ["$eq", "Matches values equal to a value", "$eq: ${}"], ["$ne", "Matches values not equal to a value", "$ne: ${}"], ["$gt", "Matches values greater than a value", "$gt: ${}"], @@ -47,18 +51,11 @@ export const QUERY_OPERATORS: MongoOperatorSpec[] = specs([ ["$lte", "Matches values less than or equal to a value", "$lte: ${}"], ["$in", "Matches any value in an array", "$in: [${}]"], ["$nin", "Matches no value in an array", "$nin: [${}]"], - ["$and", "Joins clauses with a logical AND", "$and: [${}]"], - ["$or", "Joins clauses with a logical OR", "$or: [${}]"], - ["$nor", "Joins clauses with a logical NOR", "$nor: [${}]"], ["$not", "Inverts the effect of a query expression", "$not: { ${} }"], ["$exists", "Matches documents that have the field", "$exists: true"], ["$type", "Matches documents by BSON type", '$type: "${string}"'], ["$regex", "Matches a regular expression", '$regex: "${pattern}"'], - ["$text", "Performs a text search", '$text: { $search: "${text}" }'], - ["$expr", "Uses aggregation expressions in a query", "$expr: { ${} }"], - ["$jsonSchema", "Matches documents against a JSON schema", "$jsonSchema: { ${} }"], ["$mod", "Matches values by modulo division", "$mod: [${divisor}, ${remainder}]"], - ["$where", "Matches with a JavaScript predicate", '$where: "${expression}"'], ["$all", "Matches arrays containing all the values", "$all: [${}]"], ["$elemMatch", "Matches arrays with an element matching all criteria", "$elemMatch: { ${} }"], ["$size", "Matches arrays of a given length", "$size: ${}"], @@ -72,6 +69,23 @@ export const QUERY_OPERATORS: MongoOperatorSpec[] = specs([ ["$nearSphere", "Matches points near a point on a sphere", "$nearSphere: { ${} }"], ]); +/** + * Operators that apply to the whole filter, valid at its top level and inside + * the sub-filters of `$and` / `$or` / `$nor` — never under a field. + */ +export const TOP_LEVEL_QUERY_OPERATORS: MongoOperatorSpec[] = specs([ + ["$and", "Joins clauses with a logical AND", "$and: [${}]"], + ["$or", "Joins clauses with a logical OR", "$or: [${}]"], + ["$nor", "Joins clauses with a logical NOR", "$nor: [${}]"], + ["$expr", "Uses aggregation expressions in a query", "$expr: { ${} }"], + ["$text", "Performs a text search", '$text: { $search: "${text}" }'], + ["$where", "Matches with a JavaScript predicate", '$where: "${expression}"'], + ["$jsonSchema", "Matches documents against a JSON schema", "$jsonSchema: { ${} }"], +]); + +/** Every query operator, for callers that do not care about position. */ +export const QUERY_OPERATORS: MongoOperatorSpec[] = [...FIELD_QUERY_OPERATORS, ...TOP_LEVEL_QUERY_OPERATORS]; + export const UPDATE_OPERATORS: MongoOperatorSpec[] = specs([ ["$set", "Sets field values", "$set: { ${} }"], ["$unset", "Removes fields", '$unset: { ${field}: "" }'], diff --git a/packages/app-tests/mongoCompletion.test.ts b/packages/app-tests/mongoCompletion.test.ts index a7885b21fa..5fffe53148 100644 --- a/packages/app-tests/mongoCompletion.test.ts +++ b/packages/app-tests/mongoCompletion.test.ts @@ -272,7 +272,7 @@ test("suggests aggregation stages inside aggregate pipeline", () => { test("completion context is tolerant of unfinished input", () => { const context = getMongoCompletionContext('db.getCollection("users").find({ "', 'db.getCollection("users").find({ "'.length); - assert.equal(context.mode, "field"); + assert.equal(context.mode, "filterField"); assert.equal(context.collection, "users"); }); @@ -331,6 +331,46 @@ test("auto trigger opens for useful MongoDB characters only", () => { assert.equal(shouldAutoOpenMongoCompletion("db.users.find({", "db.users.find({".length), true); }); +test("offers whole-filter operators at the top level and field operators under a field", () => { + // `$and` / `$or` belong at the top of a filter, where nothing was offered before. + const top = labels("db.users.find({ $", { fields }); + assert.ok(top.includes("$or")); + assert.ok(top.includes("$and")); + assert.ok(top.includes("$expr")); + assert.equal(top.includes("$gte"), false, "field operators are not valid at the top level"); + + // Under a field only the constraint operators apply; `{ _id: { $or: ... } }` is invalid. + const under = labels("db.users.find({ _id: { $", { fields }); + assert.ok(under.includes("$gte")); + 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"]); + + // Fields lead when nothing has been typed yet. + const bare = labels("db.users.find({ ", { fields }); + assert.ok( + bare.slice(0, fields.length).every((label) => !label.startsWith("$")), + `fields first: ${bare.join(", ")}`, + ); + assert.ok(bare.includes("$or")); +}); + +test("treats $and / $or sub-filters, $elemMatch bodies and $match as filters", () => { + for (const text of ["db.users.find({ $or: [{ $", "db.users.find({ items: { $elemMatch: { $", "db.users.aggregate([{ $match: { $"]) { + const items = labels(text, { fields }); + assert.ok(items.includes("$or"), text); + assert.equal(items.includes("$gte"), false, text); + } + assert.ok(labels("db.users.find({ $or: [{ ", { fields }).includes("name")); + assert.ok(labels("db.users.find({ $or: [{ age: { $", { fields }).includes("$gte")); +}); + +test("does not offer filter operators in update or insert documents", () => { + assert.equal(labels("db.users.updateOne({}, { $set: { ", { fields }).includes("$or"), false); + assert.equal(labels("db.users.insertOne({ $", { fields }).length, 0); +}); + test("keeps query and update operators in their own positions", () => { const filter = labels("db.users.find({ age: { $"); assert.ok(filter.includes("$gte")); @@ -473,7 +513,8 @@ test("completes both arguments of distinct", () => { // Second argument is a filter, so it behaves like find()'s. const filterArg = labels('db.users.distinct("name", { ', { fields }); - assert.deepEqual(filterArg, ["_id", "createdAt", "name", "profile.email"]); + assert.deepEqual(filterArg.slice(0, 4), ["_id", "createdAt", "name", "profile.email"]); + assert.ok(filterArg.includes("$or"), "a filter argument offers whole-filter operators after the fields"); assert.ok(labels('db.users.distinct("name", { age: { $g', { fields }).includes("$gte")); });