fix(mongodb): validate options and bound findOne queries
This commit is contained in:
1 parent
d098e3e970
commit
55f15c8e4b
14 files changed
+934
-5
No files matched your search
@@ -434,6 +434,7 @@ export const mongoDropDatabase = forward("mongoDropDatabase");
|
||||
export const mongoDropCollection = forward("mongoDropCollection");
|
||||
export const documentFindDocuments = forward("documentFindDocuments");
|
||||
export const mongoFindDocuments = forward("mongoFindDocuments");
|
||||
export const mongoFindOne = forward("mongoFindOne");
|
||||
export const mongoCountDocuments = forward("mongoCountDocuments");
|
||||
export const mongoServerVersion = forward("mongoServerVersion");
|
||||
export const mongoAggregateDocuments = forward("mongoAggregateDocuments");
|
||||
@@ -449,6 +450,9 @@ export const mongoUpdateDocuments = forward("mongoUpdateDocuments");
|
||||
export const documentDeleteDocument = forward("documentDeleteDocument");
|
||||
export const mongoDeleteDocument = forward("mongoDeleteDocument");
|
||||
export const mongoDeleteDocuments = forward("mongoDeleteDocuments");
|
||||
export const mongoFindOneAndUpdate = forward("mongoFindOneAndUpdate");
|
||||
export const mongoFindOneAndReplace = forward("mongoFindOneAndReplace");
|
||||
export const mongoFindOneAndDelete = forward("mongoFindOneAndDelete");
|
||||
|
||||
// Elasticsearch
|
||||
export const elasticsearchListIndices = forward("elasticsearchListIndices");
|
||||
|
||||
@@ -2015,6 +2015,10 @@ export async function mongoFindDocuments(connectionId: string, database: string,
|
||||
return documentFindDocuments(connectionId, database, collection, skip, limit, filter, projection, sort, executionId);
|
||||
}
|
||||
|
||||
export async function mongoFindOne(connectionId: string, database: string, collection: string, filter?: string, projection?: string, options?: string, executionId?: string): Promise<MongoDocumentResult> {
|
||||
return post("/api/mongo/find-one", { connectionId, database, collection, filter, projection, options, executionId });
|
||||
}
|
||||
|
||||
export async function documentFindDocuments(connectionId: string, database: string, collection: string, skip: number, limit: number, filter?: string, projection?: string, sort?: string, executionId?: string): Promise<MongoDocumentResult> {
|
||||
return post("/api/document-store/find-documents", { connectionId, database, collection, skip, limit, filter, projection, sort, executionId });
|
||||
}
|
||||
@@ -2128,6 +2132,18 @@ export async function mongoDeleteDocuments(connectionId: string, database: strin
|
||||
return post("/api/mongo/delete-documents", { connectionId, database, collection, filterJson, many });
|
||||
}
|
||||
|
||||
export async function mongoFindOneAndUpdate(connectionId: string, database: string, collection: string, filterJson: string, updateJson: string, optionsJson?: string): Promise<MongoDocumentResult> {
|
||||
return post("/api/mongo/find-one-and-update", { connectionId, database, collection, filterJson, updateJson, optionsJson });
|
||||
}
|
||||
|
||||
export async function mongoFindOneAndReplace(connectionId: string, database: string, collection: string, filterJson: string, replacementJson: string, optionsJson?: string): Promise<MongoDocumentResult> {
|
||||
return post("/api/mongo/find-one-and-replace", { connectionId, database, collection, filterJson, replacementJson, optionsJson });
|
||||
}
|
||||
|
||||
export async function mongoFindOneAndDelete(connectionId: string, database: string, collection: string, filterJson: string, optionsJson?: string): Promise<MongoDocumentResult> {
|
||||
return post("/api/mongo/find-one-and-delete", { connectionId, database, collection, filterJson, optionsJson });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// History
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1804,6 +1804,10 @@ export async function mongoFindDocuments(connectionId: string, database: string,
|
||||
return documentFindDocuments(connectionId, database, collection, skip, limit, filter, projection, sort, executionId);
|
||||
}
|
||||
|
||||
export async function mongoFindOne(connectionId: string, database: string, collection: string, filter?: string, projection?: string, options?: string, executionId?: string): Promise<MongoDocumentResult> {
|
||||
return invoke("mongo_find_one", { connectionId, database, collection, filter, projection, options, executionId });
|
||||
}
|
||||
|
||||
export async function documentFindDocuments(connectionId: string, database: string, collection: string, skip: number, limit: number, filter?: string, projection?: string, sort?: string, executionId?: string): Promise<MongoDocumentResult> {
|
||||
return invoke("document_find_documents", { connectionId, database, collection, skip, limit, filter, projection, sort, executionId });
|
||||
}
|
||||
@@ -1921,6 +1925,18 @@ export async function mongoDeleteDocuments(connectionId: string, database: strin
|
||||
return { affected_rows: affectedRows };
|
||||
}
|
||||
|
||||
export async function mongoFindOneAndUpdate(connectionId: string, database: string, collection: string, filterJson: string, updateJson: string, optionsJson?: string): Promise<MongoDocumentResult> {
|
||||
return invoke("mongo_find_one_and_update", { connectionId, database, collection, filterJson, updateJson, optionsJson });
|
||||
}
|
||||
|
||||
export async function mongoFindOneAndReplace(connectionId: string, database: string, collection: string, filterJson: string, replacementJson: string, optionsJson?: string): Promise<MongoDocumentResult> {
|
||||
return invoke("mongo_find_one_and_replace", { connectionId, database, collection, filterJson, replacementJson, optionsJson });
|
||||
}
|
||||
|
||||
export async function mongoFindOneAndDelete(connectionId: string, database: string, collection: string, filterJson: string, optionsJson?: string): Promise<MongoDocumentResult> {
|
||||
return invoke("mongo_find_one_and_delete", { connectionId, database, collection, filterJson, optionsJson });
|
||||
}
|
||||
|
||||
// --- History ---
|
||||
export interface HistoryEntry {
|
||||
id: string;
|
||||
|
||||
@@ -38,6 +38,9 @@ const COLLECTION_METHODS = [
|
||||
{ label: "updateMany", detail: "Update all matching documents", apply: "updateMany({}, { $set: {} })" },
|
||||
{ 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: {} })" },
|
||||
{ label: "findOneAndReplace", detail: "Atomically replace and return a document", apply: "findOneAndReplace({}, {})" },
|
||||
{ label: "findOneAndDelete", detail: "Atomically delete and return a document", apply: "findOneAndDelete({})" },
|
||||
{ label: "getIndexes", detail: "List collection indexes", apply: "getIndexes()" },
|
||||
{ label: "stats", detail: "Show collection statistics", apply: "stats()" },
|
||||
{ label: "dataSize", detail: "Total size of documents in bytes", apply: "dataSize()" },
|
||||
@@ -72,7 +75,7 @@ const FIELD_SNIPPETS = [
|
||||
const QUERY_OPERATORS = ["$eq", "$ne", "$gt", "$gte", "$lt", "$lte", "$in", "$nin", "$exists", "$regex", "$and", "$or", "$nor", "$not", "$elemMatch"];
|
||||
const UPDATE_OPERATORS = ["$set", "$unset", "$inc", "$push", "$pull", "$addToSet", "$rename", "$currentDate", "$setOnInsert"];
|
||||
const PIPELINE_STAGES = ["$match", "$project", "$group", "$sort", "$limit", "$skip", "$unwind", "$lookup", "$addFields", "$count", "$facet"];
|
||||
const QUERY_METHODS = ["find", "findOne", "countDocuments", "updateOne", "updateMany", "deleteOne", "deleteMany", "dropIndex", "dropIndexes", "sort"];
|
||||
const QUERY_METHODS = ["find", "findOne", "findOneAndUpdate", "findOneAndReplace", "findOneAndDelete", "countDocuments", "updateOne", "updateMany", "deleteOne", "deleteMany", "dropIndex", "dropIndexes", "sort"];
|
||||
|
||||
export function getMongoCompletionContext(text: string, cursor: number): MongoCompletionContext {
|
||||
const safeCursor = Math.max(0, Math.min(cursor, text.length));
|
||||
@@ -365,7 +368,7 @@ function mongoFieldApplyText(field: string, prefix: string): string {
|
||||
}
|
||||
|
||||
function findInnermostMongoCall(beforeCursor: string): { method: string; openParenIndex: number } | null {
|
||||
const callPattern = /\.(find|findOne|countDocuments|updateOne|updateMany|deleteOne|deleteMany|aggregate|sort)\s*\(/g;
|
||||
const callPattern = /\.(find|findOne|findOneAndUpdate|findOneAndReplace|findOneAndDelete|countDocuments|updateOne|updateMany|deleteOne|deleteMany|aggregate|sort)\s*\(/g;
|
||||
let match: RegExpExecArray | null;
|
||||
let result: { method: string; openParenIndex: number } | null = null;
|
||||
while ((match = callPattern.exec(beforeCursor))) {
|
||||
|
||||
@@ -10,6 +10,13 @@ export interface MongoFindCommand {
|
||||
sort?: string;
|
||||
}
|
||||
|
||||
export interface MongoFindOneCommand {
|
||||
collection: string;
|
||||
filter: string;
|
||||
projection?: string;
|
||||
options?: string;
|
||||
}
|
||||
|
||||
export interface MongoCountDocumentsCommand {
|
||||
collection: string;
|
||||
filter: string;
|
||||
@@ -41,10 +48,11 @@ export interface MongoCollectionStatsCommand {
|
||||
scale?: number;
|
||||
}
|
||||
|
||||
type MongoWriteKind = "insert" | "update" | "delete" | "createIndex" | "dropIndex" | "dropIndexes" | "dropCollection";
|
||||
type MongoWriteKind = "insert" | "update" | "delete" | "createIndex" | "dropIndex" | "dropIndexes" | "dropCollection" | "findOneAndUpdate" | "findOneAndReplace" | "findOneAndDelete";
|
||||
|
||||
export type MongoCommand =
|
||||
| ({ kind: "find" } & MongoFindCommand)
|
||||
| ({ kind: "findOne" } & MongoFindOneCommand)
|
||||
| MongoVersionCommand
|
||||
| ({ kind: "countDocuments" } & MongoCountDocumentsCommand)
|
||||
| ({ kind: "aggregate" } & MongoAggregateCommand)
|
||||
@@ -57,7 +65,10 @@ export type MongoCommand =
|
||||
| { kind: "createIndex"; collection: string; keys: string; options?: string }
|
||||
| { kind: "dropIndex"; collection: string; index: string }
|
||||
| { kind: "dropIndexes"; collection: string; indexes?: string }
|
||||
| { kind: "dropCollection"; collection: string };
|
||||
| { kind: "dropCollection"; collection: string }
|
||||
| { kind: "findOneAndUpdate"; collection: string; filter: string; update: string; options?: string }
|
||||
| { kind: "findOneAndReplace"; collection: string; filter: string; replacement: string; options?: string }
|
||||
| { kind: "findOneAndDelete"; collection: string; filter: string; options?: string };
|
||||
|
||||
export type MongoWriteCommand = Extract<MongoCommand, { kind: MongoWriteKind }>;
|
||||
|
||||
@@ -124,6 +135,103 @@ export function parseMongoFindCommand(input: string): MongoFindCommand | null {
|
||||
};
|
||||
}
|
||||
|
||||
export function parseMongoFindOneCommand(input: string): MongoFindOneCommand | null {
|
||||
const source = input.trim().replace(/;$/, "").trim();
|
||||
const target = parseCollectionMethodTarget(source, "findOne");
|
||||
if (!target) return null;
|
||||
|
||||
const args = parseMethodArgs(source, target.methodCallIndex);
|
||||
if (!args) return null;
|
||||
if (args.length > 3 && args.slice(3).some((arg) => arg.trim())) return null;
|
||||
|
||||
const filter = normalizeJsonArgument(args[0] || "{}");
|
||||
if (!filter) return null;
|
||||
|
||||
let projection: string | undefined;
|
||||
if (args[1]?.trim()) {
|
||||
const parsedProjection = normalizeJsonArgument(args[1]);
|
||||
if (!parsedProjection) return null;
|
||||
projection = parsedProjection;
|
||||
}
|
||||
|
||||
const options = args[2]?.trim() ? normalizeJsonArgument(args[2]) : undefined;
|
||||
if (args[2]?.trim() && !options) return null;
|
||||
|
||||
return {
|
||||
collection: target.collection,
|
||||
filter,
|
||||
...(projection ? { projection } : {}),
|
||||
...(options ? { options } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export interface MongoFindOneAndUpdateCommand {
|
||||
collection: string;
|
||||
filter: string;
|
||||
update: string;
|
||||
options?: string;
|
||||
}
|
||||
|
||||
export interface MongoFindOneAndReplaceCommand {
|
||||
collection: string;
|
||||
filter: string;
|
||||
replacement: string;
|
||||
options?: string;
|
||||
}
|
||||
|
||||
export interface MongoFindOneAndDeleteCommand {
|
||||
collection: string;
|
||||
filter: string;
|
||||
options?: string;
|
||||
}
|
||||
|
||||
export function parseMongoFindOneAndUpdateCommand(input: string): MongoFindOneAndUpdateCommand | null {
|
||||
const source = input.trim().replace(/;$/, "").trim();
|
||||
const target = parseCollectionMethodTarget(source, "findOneAndUpdate");
|
||||
if (!target) return null;
|
||||
|
||||
const args = parseMethodArgs(source, target.methodCallIndex);
|
||||
if (!args || args.length < 2 || args.length > 3) return null;
|
||||
const filter = normalizeJsonArgument(args[0] || "{}");
|
||||
const update = normalizeJsonArgument(args[1]);
|
||||
if (!filter || !update) return null;
|
||||
const options = args[2]?.trim() ? normalizeJsonArgument(args[2]) : undefined;
|
||||
if (args[2]?.trim() && !options) return null;
|
||||
|
||||
return { collection: target.collection, filter, update, ...(options ? { options } : {}) };
|
||||
}
|
||||
|
||||
export function parseMongoFindOneAndReplaceCommand(input: string): MongoFindOneAndReplaceCommand | null {
|
||||
const source = input.trim().replace(/;$/, "").trim();
|
||||
const target = parseCollectionMethodTarget(source, "findOneAndReplace");
|
||||
if (!target) return null;
|
||||
|
||||
const args = parseMethodArgs(source, target.methodCallIndex);
|
||||
if (!args || args.length < 2 || args.length > 3) return null;
|
||||
const filter = normalizeJsonArgument(args[0] || "{}");
|
||||
const replacement = normalizeJsonArgument(args[1]);
|
||||
if (!filter || !replacement) return null;
|
||||
const options = args[2]?.trim() ? normalizeJsonArgument(args[2]) : undefined;
|
||||
if (args[2]?.trim() && !options) return null;
|
||||
|
||||
return { collection: target.collection, filter, replacement, ...(options ? { options } : {}) };
|
||||
}
|
||||
|
||||
export function parseMongoFindOneAndDeleteCommand(input: string): MongoFindOneAndDeleteCommand | null {
|
||||
const source = input.trim().replace(/;$/, "").trim();
|
||||
const target = parseCollectionMethodTarget(source, "findOneAndDelete");
|
||||
if (!target) return null;
|
||||
|
||||
const args = parseMethodArgs(source, target.methodCallIndex);
|
||||
if (!args || args.length < 1 || args.length > 2) return null;
|
||||
const filter = normalizeJsonArgument(args[0] || "{}");
|
||||
if (!filter) return null;
|
||||
const options = args[1]?.trim() ? normalizeJsonArgument(args[1]) : undefined;
|
||||
if (args[1]?.trim() && !options) return null;
|
||||
|
||||
return { collection: target.collection, filter, ...(options ? { options } : {}) };
|
||||
}
|
||||
|
||||
export function applyMongoFindSort(input: string, column: string, direction: "asc" | "desc"): string | null {
|
||||
const source = input.trim().replace(/;$/, "").trim();
|
||||
const parsed = parseMongoFindCommand(source);
|
||||
@@ -376,6 +484,22 @@ export function parseMongoCommand(input: string): ParsedMongoCommand | null {
|
||||
const find = parseMongoFindCommand(source);
|
||||
return find ? { kind: "find", ...find } : null;
|
||||
},
|
||||
(source) => {
|
||||
const findOne = parseMongoFindOneCommand(source);
|
||||
return findOne ? { kind: "findOne", ...findOne } : null;
|
||||
},
|
||||
(source) => {
|
||||
const findOneAndUpdate = parseMongoFindOneAndUpdateCommand(source);
|
||||
return findOneAndUpdate ? { kind: "findOneAndUpdate", ...findOneAndUpdate } : null;
|
||||
},
|
||||
(source) => {
|
||||
const findOneAndReplace = parseMongoFindOneAndReplaceCommand(source);
|
||||
return findOneAndReplace ? { kind: "findOneAndReplace", ...findOneAndReplace } : null;
|
||||
},
|
||||
(source) => {
|
||||
const findOneAndDelete = parseMongoFindOneAndDeleteCommand(source);
|
||||
return findOneAndDelete ? { kind: "findOneAndDelete", ...findOneAndDelete } : null;
|
||||
},
|
||||
(source) => {
|
||||
const aggregate = parseMongoAggregateCommand(source);
|
||||
return aggregate ? { kind: "aggregate", ...aggregate } : null;
|
||||
@@ -427,7 +551,7 @@ export function evaluateMongoWriteSafety(command: MongoWriteCommand, options: Mo
|
||||
reason: "MCP MongoDB execution is read-only by default. Set DBX_MCP_ALLOW_WRITES=1 to allow write commands.",
|
||||
};
|
||||
}
|
||||
if (!options.allowDangerous && (command.kind === "update" || command.kind === "delete") && isEmptyJsonObject(command.filter)) {
|
||||
if (!options.allowDangerous && (command.kind === "update" || command.kind === "delete" || command.kind === "findOneAndUpdate" || command.kind === "findOneAndReplace" || command.kind === "findOneAndDelete") && isEmptyJsonObject(command.filter)) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: "MongoDB update/delete commands must include a non-empty filter unless DBX_MCP_ALLOW_DANGEROUS_SQL=1 is set.",
|
||||
|
||||
@@ -2782,6 +2782,21 @@ export const useQueryStore = defineStore("query", () => {
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "findOne": {
|
||||
console.info("[DBX][executeTabSql:mongo-find-one:start]", { traceId, collection: mongoCommand.collection, database: currentDatabase });
|
||||
const result = await api.mongoFindOne(tab.connectionId, currentDatabase, mongoCommand.collection, mongoCommand.filter, mongoCommand.projection, mongoCommand.options, executionId);
|
||||
const queryResult = markQueryResultRowsRaw(annotateMongoResult(mongoDocumentsToQueryResult(result.documents, performance.now() - commandStartedAt, result.total)));
|
||||
allResults.push(queryResult);
|
||||
mongoEditTarget = mongoCommands.length === 1 && queryResult.columns.includes("_id") ? { collection: mongoCommand.collection, idColumn: "_id" } : undefined;
|
||||
console.info("[DBX][executeTabSql:mongo-find-one:done]", {
|
||||
traceId,
|
||||
collection: mongoCommand.collection,
|
||||
database: currentDatabase,
|
||||
rowCount: result.documents.length,
|
||||
elapsed: elapsed(),
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "version": {
|
||||
console.info("[DBX][executeTabSql:mongo-version:start]", { traceId, database: currentDatabase });
|
||||
const version = await api.mongoServerVersion(tab.connectionId, currentDatabase, executionId);
|
||||
@@ -2862,6 +2877,37 @@ export const useQueryStore = defineStore("query", () => {
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "findOneAndUpdate":
|
||||
case "findOneAndReplace":
|
||||
case "findOneAndDelete": {
|
||||
if (options?.mongoSafety) {
|
||||
const safety = evaluateMongoWriteSafety(mongoCommand, options.mongoSafety);
|
||||
if (!safety.allowed) throw new Error(safety.reason);
|
||||
}
|
||||
console.info("[DBX][executeTabSql:mongo-find-and-modify:start]", {
|
||||
traceId,
|
||||
kind: mongoCommand.kind,
|
||||
collection: mongoCommand.collection,
|
||||
database: currentDatabase,
|
||||
});
|
||||
const result =
|
||||
mongoCommand.kind === "findOneAndUpdate"
|
||||
? await api.mongoFindOneAndUpdate(tab.connectionId, currentDatabase, mongoCommand.collection, mongoCommand.filter, mongoCommand.update, mongoCommand.options)
|
||||
: mongoCommand.kind === "findOneAndReplace"
|
||||
? await api.mongoFindOneAndReplace(tab.connectionId, currentDatabase, mongoCommand.collection, mongoCommand.filter, mongoCommand.replacement, mongoCommand.options)
|
||||
: await api.mongoFindOneAndDelete(tab.connectionId, currentDatabase, mongoCommand.collection, mongoCommand.filter, mongoCommand.options);
|
||||
allResults.push(markQueryResultRowsRaw(annotateMongoResult(mongoDocumentsToQueryResult(result.documents, performance.now() - commandStartedAt, result.total))));
|
||||
mongoEditTarget = undefined;
|
||||
console.info("[DBX][executeTabSql:mongo-find-and-modify:done]", {
|
||||
traceId,
|
||||
kind: mongoCommand.kind,
|
||||
collection: mongoCommand.collection,
|
||||
database: currentDatabase,
|
||||
rowCount: result.documents.length,
|
||||
elapsed: elapsed(),
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "insert":
|
||||
case "update":
|
||||
case "delete":
|
||||
|
||||
Reference in new issue
Block a user