diff --git a/apps/desktop/src/components/editor/useQueryEditorCompletion.ts b/apps/desktop/src/components/editor/useQueryEditorCompletion.ts index 52edf14045..b8d0f8a2fc 100644 --- a/apps/desktop/src/components/editor/useQueryEditorCompletion.ts +++ b/apps/desktop/src/components/editor/useQueryEditorCompletion.ts @@ -573,7 +573,7 @@ export function useQueryEditorCompletion(options: QueryEditorCompletionOptions) if (props.database && mongoCompletionNeedsCollections(completionContext.mode)) { try { - collections = await connectionStore.listMongoCompletionCollections(props.connectionId, props.database); + collections = await connectionStore.listMongoCompletionCollections(props.connectionId, completionContext.database ?? props.database); } catch { collections = []; } @@ -581,7 +581,7 @@ export function useQueryEditorCompletion(options: QueryEditorCompletionOptions) if (props.database && mongoCompletionNeedsFields(completionContext.mode) && completionContext.collection) { try { - fields = await connectionStore.listMongoCompletionFields(props.connectionId, props.database, completionContext.collection); + fields = await connectionStore.listMongoCompletionFields(props.connectionId, completionContext.database ?? props.database, completionContext.collection); } catch { fields = []; } diff --git a/apps/desktop/src/lib/mongo/__tests__/mongoSiblingDatabaseCompletion.spec.ts b/apps/desktop/src/lib/mongo/__tests__/mongoSiblingDatabaseCompletion.spec.ts new file mode 100644 index 0000000000..479325a5c5 --- /dev/null +++ b/apps/desktop/src/lib/mongo/__tests__/mongoSiblingDatabaseCompletion.spec.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { buildMongoCompletionItems, getMongoCompletionContext } from "@/lib/mongo/mongoCompletion"; + +function labels(text: string, cursor = text.length, collections: string[] = ["users", "orders"]) { + return buildMongoCompletionItems(text, cursor, { collections }).map((item) => item.label); +} + +describe("getSiblingDB completion targeting", () => { + it("targets the sibling database after db.getSiblingDB(…).", () => { + const text = 'db.getSiblingDB("archive").'; + expect(getMongoCompletionContext(text, text.length)).toMatchObject({ mode: "collection", database: "archive" }); + }); + + it("accepts single quotes and whitespace-tolerant spelling", () => { + const text = "db . getSiblingDB( 'archive' ) ."; + expect(getMongoCompletionContext(text, text.length)).toMatchObject({ mode: "collection", database: "archive" }); + }); + + it("keeps the sibling target while completing a collection or a method on it", () => { + expect(getMongoCompletionContext('db.getSiblingDB("archive").us', 30)).toMatchObject({ mode: "collection", database: "archive" }); + expect(getMongoCompletionContext('db.getSiblingDB("archive").users.', 33)).toMatchObject({ mode: "collectionOrMethod", collection: "users", database: "archive" }); + }); + + it("resolves the latest sibling reference across statements", () => { + const text = 'db.getSiblingDB("first").users.find(); db.getSiblingDB("second").'; + expect(getMongoCompletionContext(text, text.length)).toMatchObject({ mode: "collection", database: "second" }); + }); + + it("leaves the database unset on plain db roots", () => { + expect(getMongoCompletionContext("db.", 3)).toMatchObject({ mode: "collection", database: undefined }); + expect(getMongoCompletionContext("db.users.", 9)).toMatchObject({ mode: "collectionOrMethod", collection: "users", database: undefined }); + }); + + it("does not offer getSiblingDB under a sibling root, but still offers it at a plain db root", () => { + expect(labels('db.getSiblingDB("archive").')).not.toContain("getSiblingDB"); + expect(labels('db.getSiblingDB("archive").')).toContain("users"); + expect(labels("db.")).toContain("getSiblingDB"); + }); + + it("does not treat a variable argument as a resolved sibling database", () => { + const text = "db.getSiblingDB(name)."; + expect(getMongoCompletionContext(text, text.length).database).toBeUndefined(); + }); +}); diff --git a/apps/desktop/src/lib/mongo/mongoCompletion.ts b/apps/desktop/src/lib/mongo/mongoCompletion.ts index f61e588083..4fe6ee6efd 100644 --- a/apps/desktop/src/lib/mongo/mongoCompletion.ts +++ b/apps/desktop/src/lib/mongo/mongoCompletion.ts @@ -78,6 +78,8 @@ export interface MongoCompletionContext { replaceClosingQuote?: '"' | "'"; /** Collection the cursor's command targets, used to load field metadata. */ collection?: string; + /** Database the cursor's command targets when reached through `db.getSiblingDB(…)`, used instead of the editor's active database. */ + database?: string; /** Enclosing aggregation stage (`$lookup`, `$group`, …), when inside one. */ stage?: string; /** Collection method whose options object the cursor sits in. */ @@ -257,13 +259,14 @@ export function getMongoCompletionContext(text: string, cursor: number): MongoCo const safeCursor = Math.max(0, Math.min(cursor, text.length)); const beforeCursor = text.slice(0, safeCursor); const collection = extractActiveCollection(text, safeCursor); + const database = extractActiveDatabase(text, safeCursor); const { prefix, from } = readMongoPropertyPrefix(text, safeCursor); const replaceClosingQuote = closingQuoteAtCursor(prefix, text, safeCursor); - const at = (mode: MongoCompletionMode, stage?: string, method?: string, bulkWriteOperation?: string): MongoCompletionContext => ({ mode, prefix, from, replaceClosingQuote, collection, stage, method, bulkWriteOperation }); + const at = (mode: MongoCompletionMode, stage?: string, method?: string, bulkWriteOperation?: string): MongoCompletionContext => ({ mode, prefix, from, replaceClosingQuote, collection, database, stage, method, bulkWriteOperation }); if (isInsideMongoComment(text, safeCursor)) return { mode: "none", prefix: "", from: safeCursor }; - if (beforeCursor.endsWith("db.")) return { mode: "collection", prefix: "", from: safeCursor, collection }; + if (endsAtDbRootDot(beforeCursor)) return { mode: "collection", prefix: "", from: safeCursor, collection, database }; const getCollectionPrefix = matchGetCollectionPrefix(beforeCursor); if (getCollectionPrefix) { @@ -273,6 +276,7 @@ export function getMongoCompletionContext(text: string, cursor: number): MongoCo from: getCollectionPrefix.from, replaceClosingQuote: closingQuoteAtCursor(getCollectionPrefix.prefix, text, safeCursor), collection, + database, }; } @@ -283,18 +287,19 @@ export function getMongoCompletionContext(text: string, cursor: number): MongoCo prefix: collectionPrefix.prefix, from: collectionPrefix.from, collection, + database, }; } if (isAfterCollectionDot(beforeCursor)) { const methodPrefix = readMethodPrefix(beforeCursor); - return { mode: "method", prefix: methodPrefix.prefix, from: methodPrefix.from, collection }; + return { mode: "method", prefix: methodPrefix.prefix, from: methodPrefix.from, collection, database }; } const cursorChain = matchCursorMethodDot(beforeCursor); if (cursorChain) { const methodPrefix = readMethodPrefix(beforeCursor); - return { mode: "cursorMethod", prefix: methodPrefix.prefix, from: methodPrefix.from, collection, stage: cursorChain.countable ? "countable" : undefined }; + return { mode: "cursorMethod", prefix: methodPrefix.prefix, from: methodPrefix.from, collection, database, stage: cursorChain.countable ? "countable" : undefined }; } const call = findInnermostMongoCall(beforeCursor); @@ -328,7 +333,7 @@ export function buildMongoCompletionItemsFromContext(context: MongoCompletionCon items = rootItems(prefix); break; case "collection": - items = collectionItems(prefix, collections); + items = collectionItems(prefix, collections, context.database !== undefined); break; case "collectionOrMethod": items = collectionOrMethodItems(prefix, collections); @@ -918,9 +923,9 @@ function rootItems(prefix: string): MongoCompletionItem[] { return dedupeAndSort([...snippets, ...methods]); } -function collectionItems(prefix: string, collections: string[]): MongoCompletionItem[] { +function collectionItems(prefix: string, collections: string[], siblingRoot = false): MongoCompletionItem[] { const names = collectionNameItems(prefix, collections); - const methods = DATABASE_METHODS.filter((method) => matchesFuzzyPrefix(method.label, prefix)).map((method) => ({ + const methods = DATABASE_METHODS.filter((method) => (siblingRoot ? method.label !== "getSiblingDB" : true) && matchesFuzzyPrefix(method.label, prefix)).map((method) => ({ label: method.label, type: "function" as const, detail: method.detail, @@ -1147,22 +1152,35 @@ function readMethodPrefix(beforeCursor: string): { prefix: string; from: number return { prefix: beforeCursor.slice(from), from }; } +/** + * The database a command is addressed to: `db`, or another database through + * `db.getSiblingDB("other")`. The commands are otherwise identical, so every matcher below + * accepts either root rather than only a literal `db.`. + */ +const DB_ROOT = String.raw`db(?:\s*\.\s*getSiblingDB\s*\(\s*(?:"[^"]*"|'[^']*')\s*\))?`; +const COLLECTION_REF = String.raw`(?:[A-Za-z_][\w$-]*|getCollection\(["'][^"']+["']\))`; + +/** `db.` or `db.getSiblingDB("other").` immediately before the cursor. */ +function endsAtDbRootDot(beforeCursor: string): boolean { + return new RegExp(String.raw`(?:^|[\s;(])${DB_ROOT}\s*\.$`).test(beforeCursor); +} + function matchDbCollectionPrefix(beforeCursor: string): { prefix: string; from: number } | null { - const match = /(?:^|[\s;(])db\.([A-Za-z_][\w$-]*(?:\.[\w$-]*)*)$/.exec(beforeCursor); + const match = new RegExp(String.raw`(?:^|[\s;(])${DB_ROOT}\.([A-Za-z_][\w$-]*(?:\.[\w$-]*)*)$`).exec(beforeCursor); if (!match) return null; const prefix = match[1] ?? ""; return { prefix, from: beforeCursor.length - prefix.length }; } function matchGetCollectionPrefix(beforeCursor: string): { prefix: string; from: number } | null { - const match = /(?:^|[\s;(])db\.getCollection\(\s*(["'][^"'\\]*)$/.exec(beforeCursor); + const match = new RegExp(String.raw`(?:^|[\s;(])${DB_ROOT}\.getCollection\(\s*(["'][^"'\\]*)$`).exec(beforeCursor); if (!match) return null; const prefix = match[1] ?? ""; return { prefix, from: beforeCursor.length - prefix.length }; } function isAfterCollectionDot(beforeCursor: string): boolean { - return /(?:^|[\s;(])db\.(?:[A-Za-z_][\w$-]*|getCollection\(["'][^"']+["']\))\.[\w$-]*$/.test(beforeCursor); + return new RegExp(String.raw`(?:^|[\s;(])${DB_ROOT}\.${COLLECTION_REF}\.[\w$-]*$`).test(beforeCursor); } /** @@ -1170,7 +1188,7 @@ function isAfterCollectionDot(beforeCursor: string): boolean { * is while no other cursor method has been chained on. */ function matchCursorMethodDot(beforeCursor: string): { countable: boolean } | null { - const collectionCall = /(?:^|[\s;(])db\.(?:[A-Za-z_][\w$-]*|getCollection\(["'][^"']+["']\))\.(find|aggregate)\s*\(/g; + const collectionCall = new RegExp(String.raw`(?:^|[\s;(])${DB_ROOT}\.${COLLECTION_REF}\.(find|aggregate)\s*\(`, "g"); let lastMatch: RegExpExecArray | null = null; let match: RegExpExecArray | null; while ((match = collectionCall.exec(beforeCursor))) lastMatch = match; @@ -1301,8 +1319,8 @@ function isInsideMongoComment(text: string, cursor: number): boolean { function extractActiveCollection(text: string, cursor: number): string | undefined { const before = text.slice(0, cursor); - const getCollectionMatches = [...before.matchAll(/db\.getCollection\(["']([^"']+)["']\)/g)]; - const directMatches = [...before.matchAll(/db\.([A-Za-z_][\w$-]*)\s*\./g)].filter((match) => match[1] !== "getCollection"); + const getCollectionMatches = [...before.matchAll(new RegExp(String.raw`${DB_ROOT}\.getCollection\(["']([^"']+)["']\)`, "g"))]; + const directMatches = [...before.matchAll(new RegExp(String.raw`${DB_ROOT}\.([A-Za-z_][\w$-]*)\s*\.`, "g"))].filter((match) => match[1] !== "getCollection"); const lastGetCollection = getCollectionMatches[getCollectionMatches.length - 1]; const lastDirect = directMatches[directMatches.length - 1]; const getCollectionIndex = lastGetCollection?.index ?? -1; @@ -1311,6 +1329,17 @@ function extractActiveCollection(text: string, cursor: number): string | undefin return lastDirect?.[1]; } +/** + * The last `db.getSiblingDB("name")` before the cursor decides which database the + * command targets; plain `db.` references leave it unset so the editor's active + * database keeps applying. + */ +function extractActiveDatabase(text: string, cursor: number): string | undefined { + const before = text.slice(0, cursor); + const matches = [...before.matchAll(new RegExp(String.raw`(?:^|[\s;(])db\s*\.\s*getSiblingDB\s*\(\s*(["'])([^"']*)\1\s*\)`, "g"))]; + return matches[matches.length - 1]?.[2] || undefined; +} + function collectFieldTypes(value: unknown, prefix: string, out: Map>, depth: number) { if (depth > 4 || value == null || typeof value !== "object") return; // A wrapper such as {$oid: "..."} is one BSON value. Walking into it would offer diff --git a/packages/app-tests/mongoCompletion.test.ts b/packages/app-tests/mongoCompletion.test.ts index c3c37a2e3e..9519d37f87 100644 --- a/packages/app-tests/mongoCompletion.test.ts +++ b/packages/app-tests/mongoCompletion.test.ts @@ -589,6 +589,33 @@ test("every suggested option key parses on the method that offers it", () => { } }); +test("completes commands addressed to another database through getSiblingDB", () => { + // The parser accepts `db.getSiblingDB("x").coll.find(…)`, so the editor has to complete it the + // same way it completes `db.coll.find(…)` — including resolving the collection, which is what + // loads field names. + // Addressing another database offers what `db.` offers, except `getSiblingDB` itself — the + // parser rejects chaining it, and accepting that suggestion would produce a statement that + // cannot run. + const siblingRoot = labels('db.getSiblingDB("archive").', { collections }); + assert.deepEqual(siblingRoot, labels("db.", { collections }).filter((label: string) => label !== "getSiblingDB")); + assert.ok(!siblingRoot.includes("getSiblingDB")); + assert.equal(getMongoCompletionContext('db.getSiblingDB("archive").', 'db.getSiblingDB("archive").'.length).database, "archive"); + assert.deepEqual(labels('db.getSiblingDB("archive").user_ev', { collections }), ["user_events"]); + + const methods = labels('db.getSiblingDB("archive").users.', { collections }); + assert.ok(methods.includes("find") && methods.includes("updateOne")); + assert.equal(getMongoCompletionContext('db.getSiblingDB("archive").users.', 'db.getSiblingDB("archive").users.'.length).collection, "users"); + + assert.deepEqual(labels('db.getSiblingDB("archive").users.find({ na', { fields }), ["name"]); + assert.ok(labels('db.getSiblingDB("archive").users.find({}).', { fields }).includes("limit")); + assert.ok(labels("db.getSiblingDB('archive').users.updateOne({}, { $s", { fields }).includes("$set")); + assert.deepEqual(labels('db.getSiblingDB("archive").getCollection("user_ev', { collections }), ["user_events"]); + assert.equal( + getMongoCompletionContext('db.getSiblingDB("archive").getCollection("users").', 'db.getSiblingDB("archive").getCollection("users").'.length).collection, + "users", + ); +}); + test("completes bulkWrite operations, their fields, and the shapes inside them", () => { assert.deepEqual(labels("db.users.bulkWrite([{ "), ["deleteMany", "deleteOne", "insertOne", "replaceOne", "updateMany", "updateOne"]); assert.deepEqual(labels("db.users.bulkWrite([{ upd"), ["updateMany", "updateOne"]);