From 9c8daa2bd421c6357b7bc6193bc30a89a8ca448a Mon Sep 17 00:00:00 2001 From: zipg Date: Sun, 2 Aug 2026 09:00:00 +0800 Subject: [PATCH] fix(sql-completion): improve MongoDB editor suggestions --- .../src/components/editor/QueryEditor.vue | 27 ++- .../sql/executableStatementRangeCache.spec.ts | 10 ++ .../__tests__/sql/sqlStatementRanges.spec.ts | 7 +- apps/desktop/src/lib/mongo/mongoCompletion.ts | 168 ++++++++++++++---- .../desktop/src/lib/sql/sqlStatementRanges.ts | 2 +- packages/app-tests/mongoCompletion.test.ts | 70 +++++++- 6 files changed, 234 insertions(+), 50 deletions(-) diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index f548c22d5..86caa13ad 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -1277,7 +1277,7 @@ function executableStatementRangeStartingAt(currentView: EditorViewType, lineFro } function currentExecutableStatementRange(currentView: EditorViewType): SqlTextRange | null { - if (!supportsExecutionTargetPicker(props.databaseType)) return null; + if (!supportsExecutionTargetPicker(props.databaseType) && props.databaseType !== "mongodb") return null; executableStatementRangeCache = executableStatementRangeCacheForDoc(executableStatementRangeCache, currentView.state.doc, props.databaseType, sqlStatementParameterOptions()); return executableStatementRangeAtCursor(executableStatementRangeCache, currentView.state.selection.main.head); } @@ -2532,15 +2532,22 @@ function completionOptionForItem(item: QueryCompletionItem) { apply(view: EditorViewType, completionItem: unknown, from: number, to: number) { record(); markCompletionAccepted(item); + const replaceTo = "replaceClosingQuote" in item && item.replaceClosingQuote === view.state.sliceDoc(to, to + 1) ? to + 1 : to; if (typeof originalApply === "function") { - originalApply(view, completionItem as never, from, to); + originalApply(view, completionItem as never, from, replaceTo); } else { const insert = String(originalApply ?? item.label); view.dispatch({ - changes: { from, to, insert }, + changes: { from, to: replaceTo, insert }, selection: { anchor: from + insert.length }, }); } + if (props.databaseType === "mongodb") { + const position = view.state.selection.main.head; + if (getMongoCompletionContext(view.state.doc.toString(), position).mode === "collectionRef") { + scheduleSqlCompletionStart(view, 50); + } + } }, }; } @@ -2553,16 +2560,17 @@ function completionOptionForItem(item: QueryCompletionItem) { apply(view: EditorViewType, _completionItem: unknown, from: number, to: number) { record(); markCompletionAccepted(item); + const replaceTo = "replaceClosingQuote" in item && item.replaceClosingQuote === view.state.sliceDoc(to, to + 1) ? to + 1 : to; const insert = appendSqlCompletionSpace(item.apply ?? item.label, { enabled: shouldInsertSqlCompletionSpace() && settingsStore.editorSettings.insertSpaceAfterCompletion, itemType: item.type, - nextCharacter: view.state.sliceDoc(to, to + 1), + nextCharacter: view.state.sliceDoc(replaceTo, replaceTo + 1), }); if (codeMirrorInsertCompletionText) { - view.dispatch(codeMirrorInsertCompletionText(view.state, insert, from, to)); + view.dispatch(codeMirrorInsertCompletionText(view.state, insert, from, replaceTo)); } else { view.dispatch({ - changes: { from, to, insert }, + changes: { from, to: replaceTo, insert }, selection: { anchor: from + insert.length }, }); } @@ -2855,12 +2863,12 @@ function isEditorComposing(currentView: EditorViewType): boolean { return imeCompositionActive || currentView.compositionStarted || currentView.composing; } -function scheduleSqlCompletionStart(currentView: EditorViewType) { +function scheduleSqlCompletionStart(currentView: EditorViewType, delayMs = 0) { window.setTimeout(() => { if (!codeMirrorStartCompletion || isEditorComposing(currentView)) return; markTypedCompletionActivation(); codeMirrorStartCompletion(currentView); - }, 0); + }, delayMs); } function flushImeComposition() { @@ -2886,6 +2894,9 @@ function shouldStartSqlCompletionAfterInput(insertedText: string, removedText: s const position = currentView.state.selection.main.head; const fullDoc = currentView.state.doc.toString(); if (resolveSqlServerUseDatabaseCompletion({ sql: fullDoc, cursor: position, databaseType: props.databaseType })) return true; + if (props.databaseType === "mongodb") { + return !!(insertedText || removedText) && shouldAutoOpenMongoCompletion(fullDoc, position); + } if (!insertedText && removedText) { const completionContext = getSqlCompletionContext(fullDoc, position, sqlCompletionDialectOptions()); return isTableNameCompletionContext(completionContext) && shouldAutoOpenSqlCompletion(fullDoc, position, sqlCompletionDialectOptions()); diff --git a/apps/desktop/src/lib/__tests__/sql/executableStatementRangeCache.spec.ts b/apps/desktop/src/lib/__tests__/sql/executableStatementRangeCache.spec.ts index 8e021b1e2..acd2ebb92 100644 --- a/apps/desktop/src/lib/__tests__/sql/executableStatementRangeCache.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/executableStatementRangeCache.spec.ts @@ -3,6 +3,16 @@ import { describe, expect, it, vi } from "vitest"; import { executableStatementRangeAtCursor, executableStatementRangeCacheForDoc, executableStatementRangeStartingAt, type ExecutableStatementRangeParser } from "@/lib/sql/executableStatementRangeCache"; describe("executableStatementRangeCacheForDoc", () => { + it("tracks MongoDB commands for current-statement framing", () => { + const sql = 'db.users.find({})\n\ndb.getCollection("audit.logs").countDocuments({})'; + const doc = Text.of(sql.split("\n")); + const cache = executableStatementRangeCacheForDoc(null, doc, "mongodb"); + + expect(executableStatementRangeAtCursor(cache, sql.indexOf("users"))?.sql).toBe("db.users.find({})"); + expect(executableStatementRangeAtCursor(cache, sql.indexOf("audit.logs"))?.sql).toBe('db.getCollection("audit.logs").countDocuments({})'); + expect(executableStatementRangeAtCursor(cache, doc.line(2).from)).toBeNull(); + }); + it("reuses parsed executable statement ranges for the same document and database type", () => { const doc = Text.of(["SELECT 1;", "SELECT 2;"]); const parse = vi.fn(() => [ diff --git a/apps/desktop/src/lib/__tests__/sql/sqlStatementRanges.spec.ts b/apps/desktop/src/lib/__tests__/sql/sqlStatementRanges.spec.ts index 4e3b19596..c3c11627a 100644 --- a/apps/desktop/src/lib/__tests__/sql/sqlStatementRanges.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/sqlStatementRanges.spec.ts @@ -1018,10 +1018,11 @@ describe("currentExecutableStatementRange", () => { expect(currentExecutableStatementRange(sql, indexOf(sql, "comment"), "redis")).toBeNull(); }); - it("does not expose current statement framing for MongoDB", () => { - const sql = "db.users.find({})"; + it("uses the current MongoDB command range", () => { + const sql = 'db.users.find({})\n\ndb.getCollection("audit.logs").countDocuments({})'; - expect(currentExecutableStatementRange(sql, indexOf(sql, "users"), "mongodb")).toBeNull(); + expect(currentExecutableStatementRange(sql, indexOf(sql, "users"), "mongodb")?.sql).toBe("db.users.find({})"); + expect(currentExecutableStatementRange(sql, indexOf(sql, "audit.logs"), "mongodb")?.sql).toBe('db.getCollection("audit.logs").countDocuments({})'); }); }); diff --git a/apps/desktop/src/lib/mongo/mongoCompletion.ts b/apps/desktop/src/lib/mongo/mongoCompletion.ts index a239ac2d2..6b001ab5b 100644 --- a/apps/desktop/src/lib/mongo/mongoCompletion.ts +++ b/apps/desktop/src/lib/mongo/mongoCompletion.ts @@ -23,6 +23,8 @@ export interface MongoCompletionItem { detail?: string; info?: string; apply?: string; + filterText?: string; + replaceClosingQuote?: '"' | "'"; boost: number; } @@ -30,6 +32,8 @@ export interface MongoCompletionContext { mode: MongoCompletionMode; prefix: string; from: number; + /** The selected collection name should consume the existing closing quote. */ + replaceClosingQuote?: '"' | "'"; /** Collection the cursor's command targets, used to load field metadata. */ collection?: string; /** Enclosing aggregation stage (`$lookup`, `$group`, …), when inside one. */ @@ -68,9 +72,36 @@ const COLLECTION_METHODS = [ { label: "drop", detail: "Drop the collection", apply: "drop()" }, ] as const; +const COLLECTION_METHOD_BOOST: Record<(typeof COLLECTION_METHODS)[number]["label"], number> = { + find: 240, + findOne: 230, + aggregate: 220, + countDocuments: 210, + distinct: 200, + insertOne: 180, + insertMany: 170, + updateOne: 160, + updateMany: 150, + deleteOne: 140, + deleteMany: 130, + findOneAndUpdate: 120, + findOneAndReplace: 115, + findOneAndDelete: 110, + getIndexes: 100, + stats: 95, + createIndex: 90, + count: 80, + dataSize: 75, + storageSize: 70, + totalIndexSize: 65, + dropIndex: 50, + dropIndexes: 45, + drop: 30, +}; + /** Database-level helpers, offered next to the collection names after `db.`. */ const DATABASE_METHODS = [ - { label: "getCollection", detail: "Reference a collection by name", apply: 'getCollection("${collection}")' }, + { label: "getCollection", detail: "Reference a collection by name", apply: 'getCollection("${}")' }, { label: "version", detail: "Show the MongoDB server version", apply: "version()" }, ] as const; @@ -89,11 +120,19 @@ const CURSOR_COUNT_METHOD = { label: "count", detail: "Count the documents match const ROOT_SNIPPETS = [ { label: "db.collection.find", detail: "Find documents", apply: "db.${collection}.find({})" }, { label: "db.collection.aggregate", detail: "Aggregation pipeline", apply: "db.${collection}.aggregate([\n { $match: {} }\n])" }, - { label: "db.getCollection", detail: "Reference a collection by name", apply: 'db.getCollection("${collection}")' }, + { label: "db.getCollection", detail: "Reference a collection by name", apply: 'db.getCollection("${}")' }, { label: "use", detail: "Switch the active database", apply: "use ${database}" }, { label: "db.version", detail: "Show the MongoDB server version", apply: "db.version()" }, ] as const; +const ROOT_SNIPPET_BOOST: Record<(typeof ROOT_SNIPPETS)[number]["label"], number> = { + "db.collection.find": 350, + "db.collection.aggregate": 340, + "db.getCollection": 330, + use: 320, + "db.version": 310, +}; + /** Role of each positional argument, by collection helper. Drives cursor classification. */ type MongoArgRole = "filter" | "update" | "replacement" | "document" | "documents" | "pipeline" | "projection" | "keys" | "sortKeys" | "fieldName" | "options"; @@ -144,14 +183,23 @@ export function getMongoCompletionContext(text: string, cursor: number): MongoCo const beforeCursor = text.slice(0, safeCursor); const collection = extractActiveCollection(text, safeCursor); const { prefix, from } = readPropertyPrefix(text, safeCursor); - const at = (mode: MongoCompletionMode, stage?: string): MongoCompletionContext => ({ mode, prefix, from, collection, stage }); + const replaceClosingQuote = closingQuoteAtCursor(prefix, text, safeCursor); + const at = (mode: MongoCompletionMode, stage?: string): MongoCompletionContext => ({ mode, prefix, from, replaceClosingQuote, collection, stage }); if (isInsideMongoComment(text, safeCursor)) return { mode: "none", prefix: "", from: safeCursor }; if (beforeCursor.endsWith("db.")) return { mode: "collection", prefix: "", from: safeCursor, collection }; const getCollectionPrefix = matchGetCollectionPrefix(beforeCursor); - if (getCollectionPrefix) return { mode: "collectionRef", prefix: getCollectionPrefix.prefix, from: getCollectionPrefix.from, collection }; + if (getCollectionPrefix) { + return { + mode: "collectionRef", + prefix: getCollectionPrefix.prefix, + from: getCollectionPrefix.from, + replaceClosingQuote: closingQuoteAtCursor(getCollectionPrefix.prefix, text, safeCursor), + collection, + }; + } const collectionPrefix = matchDbCollectionPrefix(beforeCursor); if (collectionPrefix) { @@ -193,46 +241,66 @@ export function buildMongoCompletionItemsFromContext(context: MongoCompletionCon const collections = input.collections ?? []; const fields = input.fields ?? []; + let items: MongoCompletionItem[]; switch (mode) { case "none": - return []; + items = []; + break; case "root": - return rootItems(prefix); + items = rootItems(prefix); + break; case "collection": - return collectionItems(prefix, collections); + items = collectionItems(prefix, collections); + break; case "collectionOrMethod": - return collectionOrMethodItems(prefix, collections); + items = collectionOrMethodItems(prefix, collections); + break; case "collectionRef": - return collectionRefItems(prefix, collections); + items = collectionRefItems(prefix, collections); + break; case "method": - return methodItems(prefix); + items = methodItems(prefix); + break; case "cursorMethod": - return cursorMethodItems(prefix, context.stage === "countable"); + items = cursorMethodItems(prefix, context.stage === "countable"); + break; case "field": - return fieldItems(prefix, fields); + items = fieldItems(prefix, fields); + break; case "fieldPath": - return fieldPathItems(prefix, fields); + items = fieldPathItems(prefix, fields); + break; case "fieldRef": - return fieldRefItems(prefix, fields); + items = fieldRefItems(prefix, fields); + break; case "value": - return specItems(VALUE_SNIPPETS, prefix, "value", 100); + items = specItems(VALUE_SNIPPETS, prefix, "value", 100); + break; case "queryOperator": - return specItems(QUERY_OPERATORS, prefix, "query operator", 100); + items = specItems(QUERY_OPERATORS, prefix, "query operator", 100); + break; case "updateOperator": - return specItems(UPDATE_OPERATORS, prefix, "update operator", 100); + items = specItems(UPDATE_OPERATORS, prefix, "update operator", 100); + break; case "pushModifier": - return specItems(PUSH_MODIFIERS, prefix, "array update modifier", 100); + items = specItems(PUSH_MODIFIERS, prefix, "array update modifier", 100); + break; case "expression": - return [...specItems(EXPRESSION_OPERATORS, prefix, "aggregation expression", 100), ...fieldRefItems(prefix, fields, 80)]; + items = [...specItems(EXPRESSION_OPERATORS, prefix, "aggregation expression", 100), ...fieldRefItems(prefix, fields, 80)]; + break; case "accumulator": - return specItems(ACCUMULATORS, prefix, "accumulator", 100); + items = specItems(ACCUMULATORS, prefix, "accumulator", 100); + break; case "stage": - return specItems(PIPELINE_STAGES, prefix, "aggregation stage", 100); + items = specItems(PIPELINE_STAGES, prefix, "aggregation stage", 100); + break; case "stageOption": - return specItems(STAGE_OPTION_KEYS[context.stage ?? ""] ?? [], prefix, `${context.stage} option`, 100); + items = specItems(STAGE_OPTION_KEYS[context.stage ?? ""] ?? [], prefix, `${context.stage} option`, 100); + break; default: - return []; + items = []; } + return finalizeQuotedMongoCompletionItems(context, items); } /** Modes whose items are built from the target collection's sampled fields. */ @@ -562,14 +630,14 @@ function rootItems(prefix: string): MongoCompletionItem[] { type: "snippet" as const, detail: snippet.detail, apply: snippet.apply, - boost: 120, + boost: ROOT_SNIPPET_BOOST[snippet.label], })); const methods = COLLECTION_METHODS.filter((method) => matchesFuzzyPrefix(method.label, prefix)).map((method) => ({ label: method.label, type: "function" as const, detail: method.detail, apply: method.apply, - boost: 100, + boost: COLLECTION_METHOD_BOOST[method.label], })); return dedupeAndSort([...snippets, ...methods]); } @@ -603,11 +671,13 @@ function collectionOrMethodItems(prefix: string, collections: string[]): MongoCo const dot = prefix.lastIndexOf("."); const collection = prefix.slice(0, dot); const methodPrefix = prefix.slice(dot + 1); - const collectionRef = needsGetCollectionSyntax(collection) && collections.includes(collection) ? `getCollection("${escapeDoubleQuoted(collection)}")` : collection; + const hasExactCollection = collections.includes(collection); + const hasDottedCollectionCandidate = collections.some((item) => item.startsWith(`${collection}.`)); + const collectionRef = needsGetCollectionSyntax(collection) && hasExactCollection ? `getCollection("${escapeDoubleQuoted(collection)}")` : collection; const methods = methodItems(methodPrefix).map((item) => ({ ...item, apply: `${collectionRef}.${item.apply}`, - boost: Math.min(item.boost, 110), + boost: !hasExactCollection && hasDottedCollectionCandidate ? Math.min(item.boost, 110) : item.boost, })); return dedupeAndSort([...collectionNameItems(prefix, collections, 150), ...methods]); @@ -617,13 +687,16 @@ function collectionRefItems(prefix: string, collections: string[]): MongoComplet return collections .filter((collection) => matchesFuzzyPrefix(collection, prefix)) .slice(0, 100) - .map((collection) => ({ - label: collection, - type: "table" as const, - detail: "collection", - apply: quoteMongoString(collection, prefix), - boost: startsWithPrefix(collection, prefix) ? 120 : 90, - })); + .map((collection) => { + const apply = quoteMongoString(collection, prefix); + return { + label: collection, + type: "table" as const, + detail: "collection", + apply, + boost: startsWithPrefix(collection, prefix) ? 120 : 90, + }; + }); } function methodItems(prefix: string): MongoCompletionItem[] { @@ -633,7 +706,7 @@ function methodItems(prefix: string): MongoCompletionItem[] { type: "function" as const, detail: method.detail, apply: method.apply, - boost: method.label === "find" || method.label === "aggregate" ? 130 : 100, + boost: COLLECTION_METHOD_BOOST[method.label], })), ); } @@ -648,7 +721,7 @@ function cursorMethodItems(prefix: string, countable: boolean): MongoCompletionI type: "function" as const, detail: method.detail, apply: method.apply, - boost: method.label === "limit" ? 130 : 110, + boost: method.label === "limit" ? 150 : method.label === "sort" ? 140 : method.label === "skip" ? 130 : 120, })), ); } @@ -721,6 +794,24 @@ function describeField(field: MongoCompletionField, label: string): string { return field.type ? `${label} · ${field.type}` : label; } +function finalizeQuotedMongoCompletionItems(context: MongoCompletionContext, items: MongoCompletionItem[]): MongoCompletionItem[] { + const quote = context.prefix[0]; + if (quote !== '"' && quote !== "'") return items; + + return items.map((item) => { + let apply = item.apply; + if (apply?.startsWith(`${item.label}:`)) { + apply = `${quote}${item.label}${quote}${apply.slice(item.label.length)}`; + } + return { + ...item, + apply, + filterText: apply ?? `${quote}${item.label}${quote}`, + replaceClosingQuote: context.replaceClosingQuote, + }; + }); +} + /* ------------------------------------------------------------------ * * Text helpers * ------------------------------------------------------------------ */ @@ -732,6 +823,11 @@ function readPropertyPrefix(text: string, cursor: number): { prefix: string; fro return { prefix: text.slice(from, cursor), from }; } +function closingQuoteAtCursor(prefix: string, text: string, cursor: number): '"' | "'" | undefined { + const quote = prefix[0]; + return (quote === '"' || quote === "'") && text[cursor] === quote ? quote : undefined; +} + function readMethodPrefix(beforeCursor: string): { prefix: string; from: number } { const dot = beforeCursor.lastIndexOf("."); const from = dot >= 0 ? dot + 1 : beforeCursor.length; diff --git a/apps/desktop/src/lib/sql/sqlStatementRanges.ts b/apps/desktop/src/lib/sql/sqlStatementRanges.ts index 47f2e2623..0642129d7 100644 --- a/apps/desktop/src/lib/sql/sqlStatementRanges.ts +++ b/apps/desktop/src/lib/sql/sqlStatementRanges.ts @@ -2005,7 +2005,7 @@ export function executableStatementRanges(sql: string, databaseType?: DatabaseTy export function currentExecutableStatementRange(sql: string, cursorPos: number, databaseType?: DatabaseType, parameterOptions?: SqlParameterOptions): SqlTextRange | null { if (databaseType === "redis") return redisCommandRangeAtCursor(sql, cursorPos); - if (databaseType === "mongodb") return null; + if (databaseType === "mongodb") return mongoCommandRangeAtCursor(sql, cursorPos); return statementRangeAtCursor(sql, cursorPos, databaseType, parameterOptions); } diff --git a/packages/app-tests/mongoCompletion.test.ts b/packages/app-tests/mongoCompletion.test.ts index 40fa3944c..4fdb97e88 100644 --- a/packages/app-tests/mongoCompletion.test.ts +++ b/packages/app-tests/mongoCompletion.test.ts @@ -25,6 +25,14 @@ test("suggests MongoDB root snippets and methods", () => { ); }); +test("continues getCollection snippets with collection-name completion", () => { + const rootItem = buildMongoCompletionItems("", 0).find((item) => item.label === "db.getCollection"); + const methodItem = buildMongoCompletionItems("db.getC", "db.getC".length).find((item) => item.label === "getCollection"); + + assert.equal(rootItem?.apply, 'db.getCollection("${}")'); + assert.equal(methodItem?.apply, 'getCollection("${}")'); +}); + test("suggests collections after db dot", () => { const items = buildMongoCompletionItems("db.us", "db.us".length, { collections }); @@ -59,6 +67,22 @@ test("suggests dotted names inside getCollection", () => { const item = buildMongoCompletionItems(text, text.length, { collections }).find((candidate) => candidate.label === "audit.logs"); assert.equal(item?.apply, '"audit.logs"'); + assert.equal(item?.filterText, '"audit.logs"'); +}); + +test("replaces an existing closing quote when completing an emptied collection name", () => { + const text = 'db.getCollection("")'; + const cursor = text.indexOf('""') + 1; + const context = getMongoCompletionContext(text, cursor); + const item = buildMongoCompletionItems(text, cursor, { collections }).find((candidate) => candidate.label === "users"); + + assert.equal(context.mode, "collectionRef"); + assert.equal(context.from, text.indexOf('""')); + assert.equal(context.replaceClosingQuote, '"'); + assert.equal(item?.replaceClosingQuote, '"'); + assert.equal(item?.filterText, '"users"'); + assert.equal(text.slice(0, context.from) + item?.apply + text.slice(cursor + 1), 'db.getCollection("users")'); + assert.equal(shouldAutoOpenMongoCompletion(text, cursor), true); }); test("suggests collection methods after direct and getCollection references", () => { @@ -66,6 +90,22 @@ test("suggests collection methods after direct and getCollection references", () assert.ok(labels('db.getCollection("users").ag').includes("aggregate")); }); +test("prioritizes common read helpers and keeps destructive helpers last", () => { + const methodLabels = labels("db.users."); + const getCollectionMethodLabels = labels('db.getCollection("order-events").'); + + assert.deepEqual(labels("").slice(0, 5), ["db.collection.find", "db.collection.aggregate", "db.getCollection", "use", "db.version"]); + assert.deepEqual(methodLabels.slice(0, 5), ["find", "findOne", "aggregate", "countDocuments", "distinct"]); + assert.deepEqual(getCollectionMethodLabels.slice(0, 5), ["find", "findOne", "aggregate", "countDocuments", "distinct"]); + assert.deepEqual(methodLabels.slice(-3), ["dropIndex", "dropIndexes", "drop"]); + assert.deepEqual(labels("db.users.find({})."), ["limit", "sort", "skip", "count"]); +}); + +test("keeps dotted collection names ahead of methods until the collection is resolved", () => { + assert.equal(labels("db.audit.", { collections })[0], "audit.logs"); + assert.equal(labels("db.users.", { collections })[0], "find"); +}); + test("suggests collection stats methods after a collection reference", () => { const methodLabels = labels("db.users."); for (const method of ["stats", "dataSize", "storageSize", "totalIndexSize"]) { @@ -83,7 +123,7 @@ test("suggests cursor methods after find result chains", () => { assert.deepEqual( allItems.map((item) => item.label), - ["limit", "count", "skip", "sort"], + ["limit", "sort", "skip", "count"], ); assert.deepEqual( prefixedItems.map((item) => item.label), @@ -91,7 +131,7 @@ test("suggests cursor methods after find result chains", () => { ); assert.deepEqual( formattedChainItems.map((item) => item.label), - ["limit", "count", "skip", "sort"], + ["limit", "sort", "skip", "count"], ); assert.deepEqual( formattedPrefixedItems.map((item) => item.label), @@ -243,6 +283,32 @@ test("suggests query operators against a field inside a $match stage", () => { assert.equal(items.includes("$group"), false, "a stage is not valid inside a $match constraint"); }); +test("filters and replaces quoted fields, field references and operators", () => { + const quotedFieldText = 'db.users.find({ "" })'; + const quotedFieldCursor = quotedFieldText.indexOf('""') + 1; + const quotedField = buildMongoCompletionItems(quotedFieldText, quotedFieldCursor, { fields }).find((item) => item.label === "name"); + assert.equal(quotedField?.apply, '"name": '); + assert.equal(quotedField?.filterText, '"name": '); + assert.equal(quotedField?.replaceClosingQuote, '"'); + assert.equal(quotedFieldText.slice(0, getMongoCompletionContext(quotedFieldText, quotedFieldCursor).from) + quotedField?.apply + quotedFieldText.slice(quotedFieldCursor + 1), 'db.users.find({ "name": })'); + + const quotedRefText = 'db.users.aggregate([{ $group: { _id: "" } }])'; + const quotedRefCursor = quotedRefText.indexOf('""') + 1; + const quotedRef = buildMongoCompletionItems(quotedRefText, quotedRefCursor, { fields }).find((item) => item.label === "$name"); + assert.equal(quotedRef?.apply, '"$name"'); + assert.equal(quotedRef?.filterText, '"$name"'); + assert.equal(quotedRef?.replaceClosingQuote, '"'); + assert.equal(quotedRefText.slice(0, getMongoCompletionContext(quotedRefText, quotedRefCursor).from) + quotedRef?.apply + quotedRefText.slice(quotedRefCursor + 1), 'db.users.aggregate([{ $group: { _id: "$name" } }])'); + + const quotedOperatorText = 'db.users.find({ age: { "" } })'; + const quotedOperatorCursor = quotedOperatorText.indexOf('""') + 1; + const quotedOperator = buildMongoCompletionItems(quotedOperatorText, quotedOperatorCursor).find((item) => item.label === "$gte"); + assert.equal(quotedOperator?.apply, '"$gte": ${}'); + assert.equal(quotedOperator?.filterText, '"$gte": ${}'); + assert.equal(quotedOperator?.replaceClosingQuote, '"'); + assert.equal(quotedOperatorText.slice(0, getMongoCompletionContext(quotedOperatorText, quotedOperatorCursor).from) + quotedOperator?.apply + quotedOperatorText.slice(quotedOperatorCursor + 1), 'db.users.find({ age: { "$gte": ${} } })'); +}); + test("suggests accumulators, not stages, for a $group output field", () => { const items = labels('db.users.aggregate([{ $group: { _id: "$name", total: { $', { fields });