mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-21 06:00:39 +00:00
fix(mongodb): complete getSiblingDB commands against sibling database
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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"]);
|
||||
|
||||
Reference in New Issue
Block a user