feat(mongodb): support bulkWrite

This commit is contained in:
Loc Nguyen authored and GitHub committed 2026-09-17 12:54:21 +08:00
1 parent 3f3c21aae9
commit f78dc1ba16
25 files changed
+1089 -18

No files matched your search

+1
View File
@@ -823,6 +823,7 @@ export const documentUpdateDocument = forward("documentUpdateDocument");
export const mongoUpdateDocument = forward("mongoUpdateDocument");
export const mongoUpdateDocuments = forward("mongoUpdateDocuments");
export const mongoReplaceDocument = forward("mongoReplaceDocument");
export const mongoBulkWrite = forward("mongoBulkWrite");
export const documentDeleteDocument = forward("documentDeleteDocument");
export const documentSaveMeilisearchBatch = forward("documentSaveMeilisearchBatch");
export const meilisearchSearchDocuments = forward("meilisearchSearchDocuments");
+11
View File
@@ -53,6 +53,7 @@ import type {
} from "@/types/database";
import type { DetachedTabHandoff } from "@/lib/app/detachedTabHandoff";
import { normalizeRustMongoCommand, type MongoCommand } from "@/lib/mongo/mongoShellCommand";
import type { MongoBulkWriteResult } from "@/lib/mongo/mongoShellCommand";
import { BackendErrorException, type BackendError } from "@/lib/backend/errorUtils";
import { decodeMeilisearchDocumentPage, decodeMeilisearchSearchResult, type MeilisearchDocumentPage, type MeilisearchDocumentPageWire, type MeilisearchSearchResult, type MeilisearchSearchWireResult } from "@/lib/backend/meilisearchTransport";
import type { CreatedKey, EnqueuedTaskSummary, KeyCreateInput, KeyListItem, KeyPage, KeyUpdateInput, MeilisearchSystemOverview, MeilisearchTask, TaskListInput, TaskPage, TaskSelector } from "@/types/meilisearchManagement";
@@ -4670,6 +4671,16 @@ export async function documentUpdateDocument(connectionId: string, database: str
});
}
export async function mongoBulkWrite(connectionId: string, database: string, collection: string, operationsJson: string, optionsJson?: string): Promise<MongoBulkWriteResult> {
return post("/api/mongo/bulk-write", {
connectionId,
database,
collection,
operationsJson,
optionsJson,
});
}
export async function mongoReplaceDocument(connectionId: string, database: string, collection: string, filterJson: string, replacementJson: string, optionsJson?: string): Promise<{ affected_rows: number }> {
return post("/api/mongo/replace-document", {
connectionId,
+11
View File
@@ -12,6 +12,7 @@ import type { DetachedTabHandoff } from "@/lib/app/detachedTabHandoff";
import { BackendErrorException, type BackendError } from "@/lib/backend/errorUtils";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import { normalizeRustMongoCommand, type MongoCommand } from "@/lib/mongo/mongoShellCommand";
import type { MongoBulkWriteResult } from "@/lib/mongo/mongoShellCommand";
import { ExternalSqlFileTooLargeError } from "@/lib/sql/sqlFileOpen";
import { appendDebugLog, isDebugLoggingEnabled } from "@/lib/backend/debugLog";
import { decodeMeilisearchDocumentPage, decodeMeilisearchSearchResult, type MeilisearchDocumentPage, type MeilisearchDocumentPageWire, type MeilisearchSearchResult, type MeilisearchSearchWireResult } from "@/lib/backend/meilisearchTransport";
@@ -4445,6 +4446,16 @@ export async function documentUpdateDocument(connectionId: string, database: str
});
}
export async function mongoBulkWrite(connectionId: string, database: string, collection: string, operationsJson: string, optionsJson?: string): Promise<MongoBulkWriteResult> {
return invoke<MongoBulkWriteResult>("mongo_bulk_write", {
connectionId,
database,
collection,
operationsJson,
optionsJson,
});
}
export async function mongoReplaceDocument(connectionId: string, database: string, collection: string, filterJson: string, replacementJson: string, optionsJson?: string): Promise<{ affected_rows: number }> {
const affectedRows = await invoke<number>("mongo_replace_document", {
connectionId,
@@ -94,6 +94,7 @@ const COLLECTION_METHODS = [
{ label: "updateOne", detail: "Update one matching document", apply: "updateOne({}, { $set: {} })" },
{ label: "updateMany", detail: "Update all matching documents", apply: "updateMany({}, { $set: {} })" },
{ label: "replaceOne", detail: "Replace one matching document", apply: "replaceOne({}, {})" },
{ label: "bulkWrite", detail: "Run several writes in one batch", apply: "bulkWrite([\n { insertOne: { document: {} } }\n])" },
{ label: "deleteOne", detail: "Delete one matching document", apply: "deleteOne({})" },
{ label: "deleteMany", detail: "Delete all matching documents", apply: "deleteMany({})" },
{ label: "findOneAndUpdate", detail: "Atomically update and return a document", apply: "findOneAndUpdate({}, { $set: {} })" },
@@ -122,6 +123,7 @@ const COLLECTION_METHOD_BOOST: Record<(typeof COLLECTION_METHODS)[number]["label
updateOne: 160,
updateMany: 150,
replaceOne: 145,
bulkWrite: 135,
deleteOne: 140,
deleteMany: 130,
findOneAndUpdate: 120,
@@ -180,7 +182,7 @@ const ROOT_SNIPPET_BOOST: Record<(typeof ROOT_SNIPPETS)[number]["label"], number
};
/** Role of each positional argument, by collection helper. Drives cursor classification. */
type MongoArgRole = "filter" | "update" | "replacement" | "document" | "documents" | "pipeline" | "projection" | "keys" | "sortKeys" | "fieldName" | "options";
type MongoArgRole = "filter" | "update" | "replacement" | "document" | "documents" | "operations" | "pipeline" | "projection" | "keys" | "sortKeys" | "fieldName" | "options";
const METHOD_ARG_ROLES: Record<string, readonly MongoArgRole[]> = {
find: ["filter", "projection", "options"],
@@ -193,6 +195,7 @@ const METHOD_ARG_ROLES: Record<string, readonly MongoArgRole[]> = {
updateOne: ["filter", "update", "options"],
updateMany: ["filter", "update", "options"],
replaceOne: ["filter", "replacement", "options"],
bulkWrite: ["operations", "options"],
findOneAndUpdate: ["filter", "update", "options"],
findOneAndReplace: ["filter", "replacement", "options"],
insertOne: ["document", "options"],
@@ -538,6 +541,8 @@ function classifyCursorInCall(method: string, scan: MongoCallScan): MongoCursorC
return { mode: scan.stack.length === 0 ? "fieldPath" : "none" };
case "pipeline":
return classifyPipeline(scan);
// bulkWrite operations are `{ <op>: { filter, update, … } }` entries; completing inside them is a follow-up.
case "operations":
case "options":
return { mode: "none" };
default:
+126 -2
View File
@@ -49,6 +49,7 @@ const COLLECTION_METHOD_SHAPES: Record<string, MongoMethodShape> = {
updateOne: { expects: "a filter, an update, and optional options", roles: ["filter", "update", "options"] },
updateMany: { expects: "a filter, an update, and optional options", roles: ["filter", "update", "options"] },
replaceOne: { expects: "a filter, a replacement document, and optional options", roles: ["filter", "replacement", "options"] },
bulkWrite: { expects: "an array of operations and optional options", roles: ["operations", "options"] },
deleteOne: { expects: "a filter", roles: ["filter"] },
deleteMany: { expects: "a filter", roles: ["filter"] },
findOneAndUpdate: { expects: "a filter, an update, and optional options", roles: ["filter", "update", "options"] },
@@ -78,6 +79,7 @@ const SUPPORTED_COLLECTION_METHODS = [
"updateOne",
"updateMany",
"replaceOne",
"bulkWrite",
"deleteOne",
"deleteMany",
"findOneAndUpdate",
@@ -159,6 +161,14 @@ function diagnoseMongoCommand(source: string): string | null {
const tail = source.slice(closeIndex + 1).trim();
if (tail) return `Unexpected text after ${method}(...): "${tail.length > 40 ? `${tail.slice(0, 40)}…` : tail}".`;
if (method === "bulkWrite" && args[0]) {
const operations = normalizeJsonArgument(args[0]);
const problem = operations ? validateBulkWriteOperations(operations) : null;
if (problem) return problem;
const options = args[1]?.trim() ? parseMongoObjectArgument(args[1]) : null;
const optionProblem = options ? validateBulkWriteOptions(options) : null;
if (optionProblem) return optionProblem;
}
if (method === "replaceOne" && args[1]) {
const replacement = parseMongoObjectArgument(args[1]);
const operator = replacement ? Object.keys(JSON.parse(replacement) as Record<string, unknown>).find((key) => key.startsWith("$")) : undefined;
@@ -263,7 +273,7 @@ export interface MongoDistinctCommand {
filter?: string;
}
type MongoWriteKind = "runCommand" | "insert" | "update" | "replace" | "delete" | "createIndex" | "createUser" | "dropIndex" | "dropIndexes" | "dropCollection" | "findOneAndUpdate" | "findOneAndReplace" | "findOneAndDelete";
type MongoWriteKind = "runCommand" | "insert" | "update" | "replace" | "bulkWrite" | "delete" | "createIndex" | "createUser" | "dropIndex" | "dropIndexes" | "dropCollection" | "findOneAndUpdate" | "findOneAndReplace" | "findOneAndDelete";
export type MongoCommand =
| ({ kind: "find" } & MongoFindCommand)
@@ -281,6 +291,7 @@ export type MongoCommand =
| { kind: "insert"; collection: string; docsJson: string }
| { kind: "update"; collection: string; filter: string; update: string; options?: string; many: boolean }
| { kind: "replace"; collection: string; filter: string; replacement: string; options?: string }
| { kind: "bulkWrite"; collection: string; operations: string; options?: string }
| { kind: "delete"; collection: string; filter: string; many: boolean }
| { kind: "createIndex"; collection: string; keys: string; options?: string }
| { kind: "dropIndex"; collection: string; index: string }
@@ -752,6 +763,17 @@ export function parseMongoWriteCommand(input: string): MongoWriteCommand | null
return value !== null && typeof value === "object" ? { kind: "insert", collection: insert.collection, docsJson: docs } : null;
}
const bulkWrite = parseCollectionMethodTarget(source, "bulkWrite");
if (bulkWrite) {
const args = parseMethodArgs(source, bulkWrite.methodCallIndex);
if (!args || args.length < 1 || args.length > 2) return null;
const operations = normalizeJsonArgument(args[0]);
if (!operations || validateBulkWriteOperations(operations) !== null) return null;
const options = args[1]?.trim() ? parseMongoObjectArgument(args[1]) : undefined;
if (args[1]?.trim() && (!options || validateBulkWriteOptions(options) !== null)) return null;
return { kind: "bulkWrite", collection: bulkWrite.collection, operations, ...(options ? { options } : {}) };
}
const replaceOne = parseCollectionMethodTarget(source, "replaceOne");
if (replaceOne) {
const args = parseMethodArgs(source, replaceOne.methodCallIndex);
@@ -937,7 +959,7 @@ export function evaluateMongoWriteSafety(command: MongoWriteCommand, options: Mo
};
}
const filter = mongoWriteFilter(command);
const highRisk = filter !== null ? mongoFilterIsEffectivelyUnbounded(filter) : command.kind !== "insert";
const highRisk = command.kind === "bulkWrite" ? bulkWriteFilters(command.operations).some(mongoFilterIsEffectivelyUnbounded) : filter !== null ? mongoFilterIsEffectivelyUnbounded(filter) : command.kind !== "insert";
if (!options.allowDangerous && highRisk) {
return {
allowed: false,
@@ -1058,6 +1080,24 @@ export function mongoWriteToQueryResult(affectedRows: number, executionTimeMs: n
};
}
export interface MongoBulkWriteResult {
inserted_count: number;
matched_count: number;
modified_count: number;
deleted_count: number;
upserted_count: number;
}
/** One row of counts, the way the shell prints a `BulkWriteResult`. */
export function mongoBulkWriteToQueryResult(result: MongoBulkWriteResult, executionTimeMs: number): QueryResult {
return {
columns: ["insertedCount", "matchedCount", "modifiedCount", "deletedCount", "upsertedCount"],
rows: [[result.inserted_count, result.matched_count, result.modified_count, result.deleted_count, result.upserted_count]],
affected_rows: result.inserted_count + result.modified_count + result.deleted_count + result.upserted_count,
execution_time_ms: Math.max(0, Math.round(executionTimeMs)),
};
}
export function mongoCreateIndexToQueryResult(name: string, executionTimeMs: number): QueryResult {
return {
columns: ["name"],
@@ -1535,6 +1575,90 @@ function isNonEmptyRecord(value: unknown): value is Record<string, unknown> {
return isRecord(value) && Object.keys(value).length > 0;
}
const BULK_WRITE_FIELDS: Record<string, readonly string[]> = {
insertOne: ["document"],
updateOne: ["filter", "update", "upsert", "arrayFilters"],
updateMany: ["filter", "update", "upsert", "arrayFilters"],
replaceOne: ["filter", "replacement", "upsert"],
deleteOne: ["filter"],
deleteMany: ["filter"],
};
const isDocument = (value: unknown): value is Record<string, unknown> => value !== null && typeof value === "object" && !Array.isArray(value);
/**
* Validate a `bulkWrite([...])` operations array the way the shell does, mirroring the
* Rust parser: each entry is one `{ <op>: { ... } }` with exactly the fields it takes.
* Returns the problem, or null when every operation is well-formed.
*/
export function validateBulkWriteOperations(operationsJson: string): string | null {
let entries: unknown;
try {
entries = JSON.parse(operationsJson);
} catch {
return "bulkWrite() requires an array of operations.";
}
if (!Array.isArray(entries)) return "bulkWrite() requires an array of operations.";
if (entries.length === 0) return "bulkWrite() requires at least one operation.";
for (const [index, entry] of entries.entries()) {
const position = index + 1;
if (!isDocument(entry)) return `bulkWrite() operation ${position} must be a document such as { insertOne: { document: { ... } } }.`;
const keys = Object.keys(entry);
if (keys.length !== 1) return `bulkWrite() operation ${position} must have exactly one operation key.`;
const kind = keys[0]!;
const spec = entry[kind];
const allowed = BULK_WRITE_FIELDS[kind];
if (!allowed) return `bulkWrite() operation ${position} uses unsupported operation ${kind}; supported: ${Object.keys(BULK_WRITE_FIELDS).join(", ")}.`;
if (!isDocument(spec)) return `bulkWrite() operation ${position} (${kind}) must be a document.`;
const unknown = Object.keys(spec).find((key) => !allowed.includes(key));
if (unknown) return `bulkWrite() operation ${position} (${kind}) has unsupported field ${unknown}.`;
if ("upsert" in spec && typeof spec.upsert !== "boolean") return `bulkWrite() operation ${position} (${kind}) upsert must be a boolean.`;
for (const field of allowed.filter((name) => name === "document" || name === "filter" || name === "replacement")) {
if (!(field in spec)) return `bulkWrite() operation ${position} (${kind}) requires a ${field} document.`;
if (!isDocument(spec[field])) return `bulkWrite() operation ${position} (${kind}) field ${field} must be a document.`;
}
if (kind === "updateOne" || kind === "updateMany") {
const update = spec.update;
if (isDocument(update)) {
const operatorKeys = Object.keys(update);
if (operatorKeys.length === 0 || !operatorKeys.every((key) => key.startsWith("$"))) {
return `bulkWrite() operation ${position} (${kind}) update must use operators such as $set; use replaceOne for a whole document.`;
}
} else if (!Array.isArray(update)) {
return update === undefined ? `bulkWrite() operation ${position} (${kind}) requires an update.` : `bulkWrite() operation ${position} (${kind}) update must be a document or pipeline.`;
}
if ("arrayFilters" in spec && !Array.isArray(spec.arrayFilters)) return `bulkWrite() operation ${position} (${kind}) arrayFilters must be an array.`;
}
if (kind === "replaceOne") {
const operator = Object.keys(spec.replacement as Record<string, unknown>).find((key) => key.startsWith("$"));
if (operator) return `bulkWrite() operation ${position} (replaceOne) replacement must not contain update operators such as ${operator}; use updateOne to modify fields.`;
}
}
return null;
}
/** Only `ordered` is honoured, so anything else is rejected rather than dropped. */
export function validateBulkWriteOptions(optionsJson: string): string | null {
const options = JSON.parse(optionsJson) as Record<string, unknown>;
for (const [key, value] of Object.entries(options)) {
if (key !== "ordered") return `Unsupported bulkWrite() option: ${key}.`;
if (typeof value !== "boolean") return "bulkWrite() ordered option must be a boolean.";
}
return null;
}
/** Filters of every non-insert operation, for the safety checks. */
function bulkWriteFilters(operationsJson: string): string[] {
try {
const entries = JSON.parse(operationsJson) as Array<Record<string, { filter?: unknown }>>;
return entries.flatMap((entry) => Object.values(entry)).flatMap((spec) => (isDocument(spec?.filter) ? [JSON.stringify(spec.filter)] : []));
} catch {
return [];
}
}
function mongoWriteFilter(command: MongoWriteCommand): string | null {
switch (command.kind) {
case "update":
+5
View File
@@ -30,6 +30,7 @@ import {
planMongoFindPagination,
mongoUseToQueryResult,
mongoVersionToQueryResult,
mongoBulkWriteToQueryResult,
mongoWriteToQueryResult,
splitMongoCommandRanges,
type MongoAggregateSafetyOptions,
@@ -6647,6 +6648,7 @@ export const useQueryStore = defineStore("query", () => {
case "insert":
case "update":
case "replace":
case "bulkWrite":
case "delete":
case "createIndex":
case "createUser":
@@ -6670,6 +6672,9 @@ export const useQueryStore = defineStore("query", () => {
} else if (mongoCommand.kind === "update") {
const result = await api.mongoUpdateDocuments(executionConnectionId, currentDatabase, mongoCommand.collection, mongoCommand.filter, mongoCommand.update, mongoCommand.many, mongoCommand.options);
allResults.push(markQueryResultRowsRaw(annotateMongoResult(mongoWriteToQueryResult(result.affected_rows, performance.now() - commandStartedAt))));
} else if (mongoCommand.kind === "bulkWrite") {
const result = await api.mongoBulkWrite(executionConnectionId, currentDatabase, mongoCommand.collection, mongoCommand.operations, mongoCommand.options);
allResults.push(markQueryResultRowsRaw(annotateMongoResult(mongoBulkWriteToQueryResult(result, performance.now() - commandStartedAt))));
} else if (mongoCommand.kind === "replace") {
const result = await api.mongoReplaceDocument(executionConnectionId, currentDatabase, mongoCommand.collection, mongoCommand.filter, mongoCommand.replacement, mongoCommand.options);
allResults.push(markQueryResultRowsRaw(annotateMongoResult(mongoWriteToQueryResult(result.affected_rows, performance.now() - commandStartedAt))));