feat(mongodb): support db.getSiblingDB()

This commit is contained in:
Loc Nguyen authored and GitHub committed 2026-09-18 15:38:53 +08:00
1 parent b44e1080a8
commit f1e0f54a2b
9 files changed
+262 -8

No files matched your search

@@ -147,6 +147,7 @@ const COLLECTION_METHOD_BOOST: Record<(typeof COLLECTION_METHODS)[number]["label
const DATABASE_METHODS = [
{ label: "getCollection", detail: "Reference a collection by name", apply: 'getCollection("${}")' },
{ label: "version", detail: "Show the MongoDB server version", apply: "version()" },
{ label: "getSiblingDB", detail: "Run the next command against another database", apply: 'getSiblingDB("${database}")' },
{ label: "stats", detail: "Show database statistics", apply: "stats()" },
{ label: "serverStatus", detail: "Show server status", apply: "serverStatus()" },
{ label: "createCollection", detail: "Create a collection", apply: 'createCollection("${name}")' },
@@ -97,7 +97,6 @@ const SUPPORTED_COLLECTION_METHODS = [
/** Database-level methods with a supported equivalent worth pointing at. */
const DATABASE_METHOD_HINTS: Record<string, string> = {
getSiblingDB: "switch databases with `use <database>` and then run the command against db.<collection>",
adminCommand: "use db.runCommand({ ... })",
getCollectionNames: "collections are listed in the sidebar",
};
@@ -112,7 +111,7 @@ const DATABASE_METHOD_SHAPES: Record<string, MongoMethodShape> = {
runCommand: { expects: "one command document", roles: ["command"] },
};
const SUPPORTED_DATABASE_METHODS = ["version", "stats", "serverStatus", "createCollection", "dropDatabase", "createUser", "runCommand", "getCollection"];
const SUPPORTED_DATABASE_METHODS = ["version", "stats", "serverStatus", "createCollection", "dropDatabase", "createUser", "runCommand", "getCollection", "getSiblingDB"];
const SUPPORTED_VALUE_CONSTRUCTORS = ["ObjectId", "ISODate", "new Date", "NumberLong", "NumberInt", "NumberDecimal", "UUID", "BinData", "Timestamp", "MinKey", "MaxKey"];
@@ -126,6 +125,17 @@ export function describeMongoCommandParseFailure(input: string): string {
}
function diagnoseMongoCommand(source: string): string | null {
if (SIBLING_DB_PREFIX.test(source)) {
const sibling = splitSiblingDbPrefix(source);
if (!sibling) return 'getSiblingDB() requires a database name string followed by a command, for example db.getSiblingDB("app").orders.find({}).';
if (SIBLING_DB_PREFIX.test(`db${sibling.rest}`)) return "getSiblingDB() cannot be chained.";
const inner = `db${sibling.rest}`;
const parsedInner = parseMongoCommand(inner);
if (parsedInner) {
return parsedInner.command.kind === "use" || parsedInner.command.kind === "showDatabases" ? "getSiblingDB() must be followed by a collection or database method." : null;
}
return diagnoseMongoCommand(inner) ?? describeMongoCommandParseFailureBasic(inner);
}
if (/^show\s+(collections|tables)\b/i.test(source)) {
return "show collections is not supported here; collections are listed in the sidebar. Only show dbs is supported.";
}
@@ -299,6 +309,7 @@ export interface MongoDistinctCommand {
type MongoWriteKind = "runCommand" | "insert" | "update" | "replace" | "bulkWrite" | "delete" | "createIndex" | "createUser" | "dropIndex" | "dropIndexes" | "dropCollection" | "renameCollection" | "findOneAndUpdate" | "findOneAndReplace" | "findOneAndDelete";
export type MongoCommand =
| { kind: "inDatabase"; database: string; command: MongoCommand }
| ({ kind: "find" } & MongoFindCommand)
| ({ kind: "findExplain" } & MongoFindExplainCommand)
| ({ kind: "findOne" } & MongoFindOneCommand)
@@ -1021,6 +1032,15 @@ export function parseMongoCommand(input: string): ParsedMongoCommand | null {
},
];
// `db.getSiblingDB("name").<command>` runs the wrapped command against that
// database for this command only, unlike `use`.
const sibling = splitSiblingDbPrefix(text);
if (sibling) {
const inner = parseMongoCommand(`db${sibling.rest}`);
if (!inner || inner.command.kind === "use" || inner.command.kind === "showDatabases" || inner.command.kind === "inDatabase") return null;
return { text, command: { kind: "inDatabase", database: sibling.database, command: inner.command } };
}
for (const parse of parsers) {
const command = parse(text);
if (command) return { text, command };
@@ -1029,6 +1049,23 @@ export function parseMongoCommand(input: string): ParsedMongoCommand | null {
return null;
}
const SIBLING_DB_PREFIX = /^db\s*\.\s*getSiblingDB\s*\(/;
/** The database named by a leading `db.getSiblingDB("…")`, and the text after the call, which must continue with `.`. */
export function splitSiblingDbPrefix(source: string): { database: string; rest: string } | null {
const match = SIBLING_DB_PREFIX.exec(source);
if (!match) return null;
const openIndex = match[0].length - 1;
const closeIndex = findMatchingParen(source, openIndex);
if (closeIndex < 0) return null;
const args = splitTopLevel(source.slice(openIndex + 1, closeIndex));
const database = args.length === 1 ? /^(["'])([^"']+)\1$/.exec(args[0]!.trim())?.[2] : undefined;
if (!database) return null;
const rest = source.slice(closeIndex + 1);
if (!rest.trimStart().startsWith(".")) return null;
return { database, rest: rest.trimStart() };
}
export function parseMongoShowDatabasesCommand(input: string): MongoShowDatabasesCommand | null {
const source = input.trim().replace(/;$/, "").trim();
return /^show\s+(?:dbs|databases)$/i.test(source) ? { kind: "showDatabases" } : null;
@@ -123,6 +123,27 @@ describe("queryStore MongoDB execution summary", () => {
});
});
it("executes the authoritative inDatabase wrapper against the sibling database and restores the session database", async () => {
mocks.mongoFindDocuments.mockResolvedValue({ documents: [{ _id: "1" }], extended_documents: [], total: 1, total_is_exact: true });
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = store.createTab("mongo-1", "app", "Query");
await store.executeTabSql(tabId, 'db.getSiblingDB("reports").events.find();\ndb.users.find()');
expect(mocks.mongoFindDocuments).toHaveBeenNthCalledWith(1, "mongo-1", "reports", "events", expect.any(Number), expect.any(Number), "{}", undefined, undefined, undefined, expect.any(String));
expect(mocks.mongoFindDocuments).toHaveBeenNthCalledWith(2, "mongo-1", "app", "users", expect.any(Number), expect.any(Number), "{}", undefined, undefined, undefined, expect.any(String));
const tab = store.tabs.find((item) => item.id === tabId)!;
expect(tab.batchSqlExecution).toMatchObject({
total: 2,
completed: 2,
items: [
{ status: "success", statementIndex: 0 },
{ status: "success", statementIndex: 1 },
],
});
});
it("forwards the active execution ID to a pending explain and its cancellation", async () => {
let rejectExplain!: (reason: Error) => void;
mocks.mongoExplainFind.mockImplementation(
+35 -3
View File
@@ -36,6 +36,7 @@ import {
mongoScalarToQueryResult,
mongoWriteToQueryResult,
splitMongoCommandRanges,
splitSiblingDbPrefix,
type MongoAggregateSafetyOptions,
} from "@/lib/mongo/mongoShellCommand";
import { refreshLoadedMongoIndexes } from "@/lib/mongo/mongoIndexMetadata";
@@ -6494,6 +6495,13 @@ export const useQueryStore = defineStore("query", () => {
for (const parsedCommand of mongoCommands) {
let mongoCommand = parsedCommand.command;
// db.getSiblingDB("x").<command>: target that database for this command only.
const sessionDatabase = currentDatabase;
const targetsSiblingDatabase = mongoCommand.kind === "inDatabase";
if (mongoCommand.kind === "inDatabase") {
currentDatabase = mongoCommand.database;
mongoCommand = mongoCommand.command;
}
const sourceStatement = parsedCommand.text;
const sourceRange = options?.sourceOffset === undefined ? undefined : { from: options.sourceOffset + parsedCommand.from, to: options.sourceOffset + parsedCommand.to };
const commandStartedAt = performance.now();
@@ -6508,10 +6516,21 @@ export const useQueryStore = defineStore("query", () => {
// The frontend parser remains responsible for editor ranges, while
// dbx-core is authoritative for command semantics at execution time.
mongoCommand = await api.mongoParseShellCommand(sourceStatement);
// The authoritative parse keeps the `db.getSiblingDB("x").` wrapper;
// re-apply the same one-command database override before dispatch.
if (mongoCommand.kind === "inDatabase") {
currentDatabase = mongoCommand.database;
mongoCommand = mongoCommand.command;
}
switch (mongoCommand.kind) {
case "find": {
queryExecutionLog("info", "mongo-find:start", { traceId, collection: mongoCommand.collection, database: currentDatabase });
const pagePlan = planMongoFindPagination(sourceStatement, mongoCommand, options?.pagination?.offset ?? 0, normalizeResultPageSize(options?.pagination?.limit ?? settingsStore.editorSettings.pageSize));
// Pagination planning parses `db.<collection>.find(...)` directly;
// strip a `db.getSiblingDB("x")` wrapper so the plan reflects the
// wrapped command's own skip/limit chain.
const siblingPrefix = splitSiblingDbPrefix(sourceStatement);
const paginationSource = siblingPrefix ? `db${siblingPrefix.rest}` : sourceStatement;
const pagePlan = planMongoFindPagination(paginationSource, mongoCommand, options?.pagination?.offset ?? 0, normalizeResultPageSize(options?.pagination?.limit ?? settingsStore.editorSettings.pageSize));
if (!pagePlan) throw new Error(describeMongoCommandParseFailure(sourceStatement));
// A stale request can point past an explicit .limit() bound. Keep
// the backend call bounded so limit(0) cannot become unbounded.
@@ -6820,6 +6839,9 @@ export const useQueryStore = defineStore("query", () => {
// for the rest of the batch, matching the grouped-result UX.
allResults.push(annotateMongoResult(toErrorResult(error)));
mongoEditTarget = undefined;
} finally {
// A sibling-database command does not change the session database the way `use` does.
if (targetsSiblingDatabase) currentDatabase = sessionDatabase;
}
}
@@ -8408,11 +8430,17 @@ export const useQueryStore = defineStore("query", () => {
if (effectiveDbType === "mongodb") {
let mongoCommand;
let exportDatabase = location.database;
try {
mongoCommand = await api.mongoParseShellCommand(sql);
} catch {
throw new Error(QUERY_RESULT_EXPORT_UNSUPPORTED_ERROR);
}
if (mongoCommand.kind === "inDatabase") {
// `db.getSiblingDB("x").c.find()` exports against the wrapped database.
exportDatabase = mongoCommand.database;
mongoCommand = mongoCommand.command;
}
if (mongoCommand.kind !== "find") throw new Error(QUERY_RESULT_EXPORT_UNSUPPORTED_ERROR);
const pageLimit = Math.max(1, Math.trunc(exportSettings.exportBatchSize));
@@ -8422,14 +8450,18 @@ export const useQueryStore = defineStore("query", () => {
let totalRows = typeof tab.resultTotalRowCount === "number" ? Math.min(tab.resultTotalRowCount, exportRowLimit) : null;
const exportStartedAt = performance.now();
const exportExecutionId = uuid();
// Pagination planning parses `db.<collection>.find(...)` directly; strip a
// `db.getSiblingDB("x")` wrapper the same way the execution loop does.
const exportSiblingPrefix = splitSiblingDbPrefix(sql);
const exportPaginationSource = exportSiblingPrefix ? `db${exportSiblingPrefix.rest}` : sql;
while (documents.length < exportRowLimit) {
const remaining = exportRowLimit - documents.length;
const plan = planMongoFindPagination(sql, mongoCommand, pageOffset, Math.min(pageLimit, remaining));
const plan = planMongoFindPagination(exportPaginationSource, mongoCommand, pageOffset, Math.min(pageLimit, remaining));
if (!plan) throw new Error(QUERY_RESULT_EXPORT_UNSUPPORTED_ERROR);
if (plan.requestLimit === 0) break;
const result = await api.mongoFindDocuments(location.connectionId, location.database, mongoCommand.collection, plan.requestSkip, plan.requestLimit, mongoCommand.filter, mongoCommand.projection, mongoCommand.sort, mongoCommand.collation, exportExecutionId);
const result = await api.mongoFindDocuments(location.connectionId, exportDatabase, mongoCommand.collection, plan.requestSkip, plan.requestLimit, mongoCommand.filter, mongoCommand.projection, mongoCommand.sort, mongoCommand.collation, exportExecutionId);
const pageDocuments = result.documents.slice(0, plan.requestLimit);
documents.push(...pageDocuments);