fix(mongodb): complete getSiblingDB commands against sibling database

This commit is contained in:
Loc Nguyen
2026-09-28 01:09:03 +08:00
committed by GitHub
parent ab5c4f57c0
commit cee14bb631
4 changed files with 115 additions and 15 deletions
@@ -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 = [];
}
@@ -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();
});
});
+42 -13
View File
@@ -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<string, Set<string>>, 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
+27
View File
@@ -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"]);