fix(mongodb): support multi-command execution
* fix mongodb multi-command execution * fix(connection): 复制连接时保持在同一组内 - 修改 `addConnection` 方法以支持指定目标组 ID - 更新 `duplicateConnection` 函数以传递目标组 ID - 添加测试用例以验证复制连接的功能 * fix: add MongoDB data transfer entry * feat(editor): SQL 编辑器选区大小写转换 Co-authored-by: staff <staff@qimaos-MacBook-Pro.local> * fix(editor): 修复SQL快捷执行导致执行全量SQL的问题 (#2384) - 将执行SQL语句的逻辑从直接请求改为通过事件发射 - 增强了代码的可扩展性和可维护性 * fix(sidebar): cache tooltip overflow measurement * fix(dialog): allow dragging window from modal overlay * fix(oracle): reuse shared pool for data tabs * fix(sidebar): reduce comment text repaint cost * fix(saved-sql): preserve folder on metadata save * fix(agent): execute transaction control SQL as statements * feat(vector): add Qdrant/Milvus collection dimension display (#2396) * fix(oracle): 修复时间字段编辑和表结构修改报错 Co-authored-by: staff <staff@qimaos-MacBook-Pro.local> * fix(agents): limit common version bumps --------- Co-authored-by: 二丫讲梵 <eryajf@163.com> Co-authored-by: zipg <geeky@foxmail.com> Co-authored-by: staff <staff@qimaos-MacBook-Pro.local> Co-authored-by: t8y2 <1156263951@qq.com> Co-authored-by: lexmin0412 <zhangle_dev@outlook.com>
This commit is contained in:
9 files changed
+861
-272
No files matched your search
@@ -342,6 +342,14 @@ describe("executableStatementRanges", () => {
|
||||
expect(ranges.map((range) => range.from)).toEqual([0, sql.indexOf("DEL")]);
|
||||
});
|
||||
|
||||
it("returns MongoDB command ranges for newline-separated shell commands", () => {
|
||||
const sql = 'use archive\ndb.users.find({ status: "open" })\n .limit(5)';
|
||||
const ranges = executableStatementRanges(sql, "mongodb");
|
||||
|
||||
expect(rangeSqlTexts(ranges)).toEqual(["use archive", 'db.users.find({ status: "open" })\n .limit(5)']);
|
||||
expect(ranges.map((range) => range.from)).toEqual([0, sql.indexOf("db.users.find")]);
|
||||
});
|
||||
|
||||
it("does not split executable Oracle PL/SQL ranges at inner statement starts", () => {
|
||||
expect(rangeSqlTexts(executableStatementRanges(oraclePlSqlFixture, "oracle"))).toEqual([oraclePlSqlFixture.slice(0, oraclePlSqlFixture.indexOf("\n/")), "SELECT 1"]);
|
||||
});
|
||||
|
||||
@@ -31,7 +31,15 @@ export interface MongoVersionCommand {
|
||||
kind: "version";
|
||||
}
|
||||
|
||||
export type MongoWriteCommand =
|
||||
type MongoWriteKind = "insert" | "update" | "delete" | "createIndex" | "dropIndex" | "dropIndexes";
|
||||
|
||||
export type MongoCommand =
|
||||
| ({ kind: "find" } & MongoFindCommand)
|
||||
| MongoVersionCommand
|
||||
| ({ kind: "countDocuments" } & MongoCountDocumentsCommand)
|
||||
| ({ kind: "aggregate" } & MongoAggregateCommand)
|
||||
| ({ kind: "getIndexes" } & MongoGetIndexesCommand)
|
||||
| ({ kind: "use" } & MongoUseCommand)
|
||||
| { kind: "insert"; collection: string; docsJson: string }
|
||||
| { kind: "update"; collection: string; filter: string; update: string; many: boolean }
|
||||
| { kind: "delete"; collection: string; filter: string; many: boolean }
|
||||
@@ -39,6 +47,18 @@ export type MongoWriteCommand =
|
||||
| { kind: "dropIndex"; collection: string; index: string }
|
||||
| { kind: "dropIndexes"; collection: string; indexes?: string };
|
||||
|
||||
export type MongoWriteCommand = Extract<MongoCommand, { kind: MongoWriteKind }>;
|
||||
|
||||
export interface ParsedMongoCommand {
|
||||
text: string;
|
||||
command: MongoCommand;
|
||||
}
|
||||
|
||||
export interface ParsedMongoCommandRange extends ParsedMongoCommand {
|
||||
from: number;
|
||||
to: number;
|
||||
}
|
||||
|
||||
export interface MongoAggregateSafetyOptions {
|
||||
allowWrites?: boolean;
|
||||
allowDangerous?: boolean;
|
||||
@@ -241,6 +261,65 @@ export function parseMongoWriteCommand(input: string): MongoWriteCommand | null
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseMongoCommand(input: string): ParsedMongoCommand | null {
|
||||
const text = trimMongoOuterComments(input);
|
||||
if (!text) return null;
|
||||
|
||||
// Keep the more specific readers ahead of generic write parsing so the
|
||||
// returned kind matches the result renderer we want to use downstream.
|
||||
const parsers: Array<(source: string) => MongoCommand | null> = [
|
||||
(source) => {
|
||||
const find = parseMongoFindCommand(source);
|
||||
return find ? { kind: "find", ...find } : null;
|
||||
},
|
||||
(source) => {
|
||||
const version = parseMongoVersionCommand(source);
|
||||
return version ?? null;
|
||||
},
|
||||
(source) => {
|
||||
const count = parseMongoCountDocumentsCommand(source);
|
||||
return count ? { kind: "countDocuments", ...count } : null;
|
||||
},
|
||||
(source) => {
|
||||
const aggregate = parseMongoAggregateCommand(source);
|
||||
return aggregate ? { kind: "aggregate", ...aggregate } : null;
|
||||
},
|
||||
(source) => {
|
||||
const getIndexes = parseMongoGetIndexesCommand(source);
|
||||
return getIndexes ? { kind: "getIndexes", ...getIndexes } : null;
|
||||
},
|
||||
(source) => {
|
||||
const write = parseMongoWriteCommand(source);
|
||||
return write ?? null;
|
||||
},
|
||||
(source) => {
|
||||
const use = parseMongoUseCommand(source);
|
||||
return use ? { kind: "use", ...use } : null;
|
||||
},
|
||||
];
|
||||
|
||||
for (const parse of parsers) {
|
||||
const command = parse(text);
|
||||
if (command) return { text, command };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function splitMongoCommands(input: string): ParsedMongoCommand[] {
|
||||
return splitMongoCommandRanges(input).map(({ from: _from, to: _to, ...command }) => command);
|
||||
}
|
||||
|
||||
export function splitMongoCommandRanges(input: string): ParsedMongoCommandRange[] {
|
||||
const commands: ParsedMongoCommandRange[] = [];
|
||||
for (const segment of splitMongoCommandTextRanges(input)) {
|
||||
const parsed = parseMongoCommand(segment.text);
|
||||
if (!parsed) return [];
|
||||
commands.push({ from: segment.from, to: segment.to, ...parsed });
|
||||
}
|
||||
return commands;
|
||||
}
|
||||
|
||||
export function evaluateMongoWriteSafety(command: MongoWriteCommand, options: MongoAggregateSafetyOptions): { allowed: boolean; reason?: string } {
|
||||
if (!options.allowWrites) {
|
||||
return {
|
||||
@@ -447,6 +526,263 @@ function parseMethodArgs(source: string, methodCallIndex: number): string[] | nu
|
||||
return splitTopLevel(source.slice(openIndex + 1, closeIndex));
|
||||
}
|
||||
|
||||
interface MongoTextRange {
|
||||
from: number;
|
||||
to: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
function splitMongoCommandTextRanges(input: string): MongoTextRange[] {
|
||||
const commands: MongoTextRange[] = [];
|
||||
for (const segment of splitMongoSemicolonSeparatedSegments(input)) {
|
||||
const parsed = parseMongoCommand(segment.text);
|
||||
if (parsed) {
|
||||
commands.push({ ...segment, text: parsed.text });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Mongo shell users often omit semicolons and rely on one top-level
|
||||
// command per line, so fall back to a conservative newline split.
|
||||
const softSplit = splitMongoSegmentAtSoftStarts(segment);
|
||||
if (softSplit.length > 1) {
|
||||
commands.push(...softSplit);
|
||||
continue;
|
||||
}
|
||||
|
||||
commands.push(segment);
|
||||
}
|
||||
return commands;
|
||||
}
|
||||
|
||||
function splitMongoSemicolonSeparatedSegments(input: string): MongoTextRange[] {
|
||||
const segments: MongoTextRange[] = [];
|
||||
let start = 0;
|
||||
let depth = 0;
|
||||
let quote: string | null = null;
|
||||
let escaped = false;
|
||||
let lineComment = false;
|
||||
let blockComment = false;
|
||||
|
||||
// Respect semicolons only when they appear at the top level; JSON literals,
|
||||
// strings and comments are allowed to contain semicolons verbatim.
|
||||
for (let i = 0; i < input.length; i += 1) {
|
||||
const char = input[i] ?? "";
|
||||
const next = input[i + 1] ?? "";
|
||||
|
||||
if (lineComment) {
|
||||
if (char === "\n") lineComment = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (blockComment) {
|
||||
if (char === "*" && next === "/") {
|
||||
blockComment = false;
|
||||
i += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (quote) {
|
||||
if (escaped) escaped = false;
|
||||
else if (char === "\\") escaped = true;
|
||||
else if (char === quote) quote = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "/" && next === "/") {
|
||||
lineComment = true;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "/" && next === "*") {
|
||||
blockComment = true;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '"' || char === "'" || char === "`") {
|
||||
quote = char;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "{" || char === "[" || char === "(") depth += 1;
|
||||
else if ((char === "}" || char === "]" || char === ")") && depth > 0) depth -= 1;
|
||||
else if (char === ";" && depth === 0) {
|
||||
pushMongoSegment(segments, input, start, i);
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
|
||||
pushMongoSegment(segments, input, start, input.length);
|
||||
return segments;
|
||||
}
|
||||
|
||||
function splitMongoSegmentAtSoftStarts(segment: MongoTextRange): MongoTextRange[] {
|
||||
const boundaries = mongoTopLevelCommandLineStarts(segment.text);
|
||||
if (boundaries.length <= 1) return [segment];
|
||||
|
||||
const segments: MongoTextRange[] = [];
|
||||
let start = boundaries[0] ?? 0;
|
||||
for (let index = 1; index < boundaries.length; index += 1) {
|
||||
const boundary = boundaries[index] ?? 0;
|
||||
const candidate = trimMongoOuterCommentRange(segment.text, start, boundary);
|
||||
// Only accept newline-based splitting when every slice is a valid command;
|
||||
// otherwise keep the original text intact and let normal parsing reject it.
|
||||
if (!candidate || !parseMongoCommand(candidate.text)) return [segment];
|
||||
segments.push({
|
||||
from: segment.from + candidate.from,
|
||||
to: segment.from + candidate.to,
|
||||
text: candidate.text,
|
||||
});
|
||||
start = boundary;
|
||||
}
|
||||
|
||||
const last = trimMongoOuterCommentRange(segment.text, start, segment.text.length);
|
||||
if (!last || !parseMongoCommand(last.text)) return [segment];
|
||||
segments.push({
|
||||
from: segment.from + last.from,
|
||||
to: segment.from + last.to,
|
||||
text: last.text,
|
||||
});
|
||||
return segments;
|
||||
}
|
||||
|
||||
function mongoTopLevelCommandLineStarts(segment: string): number[] {
|
||||
const starts: number[] = [];
|
||||
let depth = 0;
|
||||
let quote: string | null = null;
|
||||
let escaped = false;
|
||||
let lineComment = false;
|
||||
let blockComment = false;
|
||||
let lineStart = 0;
|
||||
let firstNonWhitespaceOnLine = -1;
|
||||
|
||||
for (let i = 0; i < segment.length; i += 1) {
|
||||
const char = segment[i] ?? "";
|
||||
const next = segment[i + 1] ?? "";
|
||||
|
||||
if (char === "\n") {
|
||||
if (lineComment) lineComment = false;
|
||||
lineStart = i + 1;
|
||||
firstNonWhitespaceOnLine = -1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (lineComment) continue;
|
||||
|
||||
if (blockComment) {
|
||||
if (char === "*" && next === "/") {
|
||||
blockComment = false;
|
||||
i += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (quote) {
|
||||
if (escaped) escaped = false;
|
||||
else if (char === "\\") escaped = true;
|
||||
else if (char === quote) quote = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "/" && next === "/") {
|
||||
lineComment = true;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "/" && next === "*") {
|
||||
blockComment = true;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '"' || char === "'" || char === "`") {
|
||||
if (firstNonWhitespaceOnLine === -1 && !/\s/.test(char)) firstNonWhitespaceOnLine = i;
|
||||
quote = char;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "{" || char === "[" || char === "(") depth += 1;
|
||||
else if ((char === "}" || char === "]" || char === ")") && depth > 0) depth -= 1;
|
||||
|
||||
if (firstNonWhitespaceOnLine === -1 && !/\s/.test(char)) {
|
||||
firstNonWhitespaceOnLine = i;
|
||||
if (depth === 0 && char !== "." && isMongoCommandLineStart(segment, i)) starts.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
return starts.length > 0 ? starts : [lineStart];
|
||||
}
|
||||
|
||||
function isMongoCommandLineStart(segment: string, index: number): boolean {
|
||||
const rest = segment.slice(index);
|
||||
return /^use\b/i.test(rest) || /^db(?:\s*\.|\b)/i.test(rest);
|
||||
}
|
||||
|
||||
function pushMongoSegment(segments: MongoTextRange[], source: string, from: number, to: number) {
|
||||
const trimmed = trimMongoOuterCommentRange(source, from, to);
|
||||
if (trimmed) segments.push(trimmed);
|
||||
}
|
||||
|
||||
function trimMongoOuterComments(source: string): string {
|
||||
let value = source.trim();
|
||||
while (value) {
|
||||
const next = value.replace(/^(?:\/\/[^\n]*(?:\n|$)|\/\*[\s\S]*?\*\/)\s*/u, "");
|
||||
if (next === value) break;
|
||||
value = next.trimStart();
|
||||
}
|
||||
while (value) {
|
||||
const next = value.replace(/\s*(?:\/\/[^\n]*|\/\*[\s\S]*?\*\/)\s*$/u, "");
|
||||
if (next === value) break;
|
||||
value = next.trimEnd();
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function trimMongoOuterCommentRange(source: string, from: number, to: number): MongoTextRange | null {
|
||||
let start = from;
|
||||
let end = to;
|
||||
|
||||
while (start < end) {
|
||||
const value = source.slice(start, end);
|
||||
const trimmed = value.trimStart();
|
||||
if (trimmed !== value) {
|
||||
start += value.length - trimmed.length;
|
||||
continue;
|
||||
}
|
||||
const next = value.replace(/^(?:\/\/[^\n]*(?:\n|$)|\/\*[\s\S]*?\*\/)\s*/u, "");
|
||||
if (next !== value) {
|
||||
start += value.length - next.length;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
while (start < end) {
|
||||
const value = source.slice(start, end);
|
||||
const trimmed = value.trimEnd();
|
||||
if (trimmed !== value) {
|
||||
end -= value.length - trimmed.length;
|
||||
continue;
|
||||
}
|
||||
const next = value.replace(/\s*(?:\/\/[^\n]*|\/\*[\s\S]*?\*\/)\s*$/u, "");
|
||||
if (next !== value) {
|
||||
end -= value.length - next.length;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (start >= end) return null;
|
||||
return {
|
||||
from: start,
|
||||
to: end,
|
||||
text: source.slice(start, end),
|
||||
};
|
||||
}
|
||||
|
||||
function convertSingleQuotedStrings(source: string): string {
|
||||
let result = "";
|
||||
let copiedUntil = 0;
|
||||
|
||||
@@ -52,6 +52,11 @@ export async function resolveExecutableSqlWithBackend(fullSql: string, selectedS
|
||||
const trimmedSelection = selectedSql.trim();
|
||||
if (trimmedSelection) return trimmedSelection;
|
||||
|
||||
// MongoDB uses dedicated per-command gutter actions for "current command";
|
||||
// the main editor execute action keeps its long-standing "run all text"
|
||||
// behavior when nothing is selected.
|
||||
if (options?.databaseType === "mongodb") return fullSql;
|
||||
|
||||
if (options?.mode === "current" && options.cursorPos !== undefined) {
|
||||
return await api.findStatementAtCursor(fullSql, options.cursorPos, options.databaseType);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { SqlExecutionCandidate } from "./sqlExecutionTarget";
|
||||
import { splitMongoCommandRanges } from "./mongoShellCommand";
|
||||
import type { DatabaseType } from "@/types/database";
|
||||
|
||||
/**
|
||||
@@ -1103,6 +1104,7 @@ function normalizeSql(sql: string): string {
|
||||
*/
|
||||
export function executableStatementRanges(sql: string, databaseType?: DatabaseType): SqlTextRange[] {
|
||||
if (databaseType === "redis") return redisExecutableCommandRanges(sql);
|
||||
if (databaseType === "mongodb") return splitMongoCommandRanges(sql).map(({ from, to, text }) => ({ from, to, sql: text }));
|
||||
return splitSqlStatementRanges(sql, databaseType).flatMap((statement) => splitStatementRangeAtSoftStarts(sql, statement, databaseType).map((range) => rangeFor(range, sql)));
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user