feat(mongodb): support bulkWrite
This commit is contained in:
1 parent
3f3c21aae9
commit
f78dc1ba16
25 files changed
+1089
-18
No files matched your search
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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))));
|
||||
|
||||
Reference in new issue
Block a user