diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index 64fbd7836f..8e0449ca39 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -598,6 +598,8 @@ function executeSqlStatementFromGutter(currentView: EditorViewType, line: { from if (!statementRange) return false; event.preventDefault(); event.stopPropagation(); + // Gutter play is always scoped to the statement/command for that line, even + // when the main editor execute action would run the full document. emit("execute", statementRange.sql); currentView.focus(); return true; diff --git a/apps/desktop/src/lib/__tests__/sqlStatementRanges.spec.ts b/apps/desktop/src/lib/__tests__/sqlStatementRanges.spec.ts index 889a3b0d38..e2e1cf0944 100644 --- a/apps/desktop/src/lib/__tests__/sqlStatementRanges.spec.ts +++ b/apps/desktop/src/lib/__tests__/sqlStatementRanges.spec.ts @@ -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"]); }); diff --git a/apps/desktop/src/lib/mongoShellCommand.ts b/apps/desktop/src/lib/mongoShellCommand.ts index 9789e3d006..d0c49aa08c 100644 --- a/apps/desktop/src/lib/mongoShellCommand.ts +++ b/apps/desktop/src/lib/mongoShellCommand.ts @@ -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; + +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; diff --git a/apps/desktop/src/lib/sqlExecutionTarget.ts b/apps/desktop/src/lib/sqlExecutionTarget.ts index cede16efc0..8bb5555a6b 100644 --- a/apps/desktop/src/lib/sqlExecutionTarget.ts +++ b/apps/desktop/src/lib/sqlExecutionTarget.ts @@ -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); } diff --git a/apps/desktop/src/lib/sqlStatementRanges.ts b/apps/desktop/src/lib/sqlStatementRanges.ts index a4a1787988..7adf14ca1b 100644 --- a/apps/desktop/src/lib/sqlStatementRanges.ts +++ b/apps/desktop/src/lib/sqlStatementRanges.ts @@ -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))); } diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index d747d8f7a9..a78d52c336 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -19,13 +19,7 @@ import { mongoUseToQueryResult, mongoVersionToQueryResult, mongoWriteToQueryResult, - parseMongoAggregateCommand, - parseMongoCountDocumentsCommand, - parseMongoFindCommand, - parseMongoGetIndexesCommand, - parseMongoWriteCommand, - parseMongoUseCommand, - parseMongoVersionCommand, + splitMongoCommands, type MongoAggregateSafetyOptions, } from "@/lib/mongoShellCommand"; import { redisCommandResultToQueryResult } from "@/lib/redisQueryResult"; @@ -1605,14 +1599,24 @@ export const useQueryStore = defineStore("query", () => { let useAgentResultSession = false; try { const connStore = useConnectionStore(); + let conn = connStore.getConfig(tab.connectionId); + const parsedMongoCommands = conn?.db_type === "mongodb" ? splitMongoCommands(sql) : undefined; + let mongoCommands = parsedMongoCommands ?? []; + const mongoNeedsConnection = mongoCommands.some(({ command }) => command.kind !== "use"); + if (options?.skipEnsureConnected) { console.info("[DBX][executeTabSql:ensure-connected:skip]", { traceId, elapsed: elapsed(), reason: "caller" }); + } else if (conn?.db_type === "mongodb" && mongoCommands.length > 0 && !mongoNeedsConnection) { + console.info("[DBX][executeTabSql:ensure-connected:skip]", { traceId, elapsed: elapsed(), reason: "mongo-use-only" }); } else { console.info("[DBX][executeTabSql:ensure-connected:start]", { traceId, elapsed: elapsed() }); await connStore.ensureConnected(tab.connectionId); console.info("[DBX][executeTabSql:ensure-connected:done]", { traceId, elapsed: elapsed() }); } - const conn = connStore.getConfig(tab.connectionId); + conn = connStore.getConfig(tab.connectionId); + if (parsedMongoCommands === undefined && conn?.db_type === "mongodb") { + mongoCommands = splitMongoCommands(sql); + } const effectiveDbType = effectiveDatabaseTypeForConnection(conn); const useAgentCursor = usesAgentCursorForQuery(conn?.db_type); const queryTimeoutSecs = queryTimeoutSecsForConnection(conn); @@ -1690,6 +1694,195 @@ export const useQueryStore = defineStore("query", () => { return; } + if (mongoCommands.length > 0) { + console.info("[DBX][executeTabSql:mongo:start]", { traceId, database: tab.database, commandCount: mongoCommands.length, sql }); + + const allResults: QueryResult[] = []; + // Track the effective db as we walk the batch so later commands observe + // earlier `use ...` statements in the same editor selection. + let currentDatabase = tab.database; + let mongoEditTarget: QueryTab["mongoEditTarget"] | undefined; + + for (const parsedCommand of mongoCommands) { + const mongoCommand = parsedCommand.command; + const commandStartedAt = performance.now(); + try { + switch (mongoCommand.kind) { + case "find": { + console.info("[DBX][executeTabSql:mongo-find:start]", { traceId, collection: mongoCommand.collection, database: currentDatabase }); + const result = await api.mongoFindDocuments(tab.connectionId, currentDatabase, mongoCommand.collection, mongoCommand.skip, mongoCommand.limit, mongoCommand.filter, mongoCommand.projection, mongoCommand.sort, executionId); + const queryResult = markQueryResultRowsRaw(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:done]", { + traceId, + collection: mongoCommand.collection, + database: currentDatabase, + rowCount: result.documents.length, + total: result.total, + elapsed: elapsed(), + }); + break; + } + case "version": { + console.info("[DBX][executeTabSql:mongo-version:start]", { traceId, database: currentDatabase }); + const version = await api.mongoServerVersion(tab.connectionId, currentDatabase, executionId); + allResults.push(markQueryResultRowsRaw(mongoVersionToQueryResult(version, performance.now() - commandStartedAt))); + mongoEditTarget = undefined; + console.info("[DBX][executeTabSql:mongo-version:done]", { + traceId, + database: currentDatabase, + version, + elapsed: elapsed(), + }); + break; + } + case "countDocuments": { + console.info("[DBX][executeTabSql:mongo-count:start]", { traceId, collection: mongoCommand.collection, database: currentDatabase }); + const result = await api.mongoFindDocuments(tab.connectionId, currentDatabase, mongoCommand.collection, 0, 1, mongoCommand.filter, undefined, undefined, executionId); + allResults.push(markQueryResultRowsRaw(mongoCountToQueryResult(result.total, performance.now() - commandStartedAt))); + mongoEditTarget = undefined; + console.info("[DBX][executeTabSql:mongo-count:done]", { + traceId, + collection: mongoCommand.collection, + database: currentDatabase, + total: result.total, + elapsed: elapsed(), + }); + break; + } + case "aggregate": { + if (options?.mongoSafety) { + const safety = evaluateMongoAggregateSafety(mongoCommand, options.mongoSafety); + if (!safety.allowed) throw new Error(safety.reason); + } + console.info("[DBX][executeTabSql:mongo-aggregate:start]", { traceId, collection: mongoCommand.collection, database: currentDatabase }); + const aggregateMaxRows = normalizeResultPageSize(pageLimit ?? options?.pagination?.limit ?? settingsStore.editorSettings.pageSize); + const result = await api.mongoAggregateDocuments(tab.connectionId, currentDatabase, mongoCommand.collection, mongoCommand.pipeline, aggregateMaxRows, executionId); + allResults.push(markQueryResultRowsRaw(mongoDocumentsToQueryResult(result.documents, performance.now() - commandStartedAt, result.total))); + mongoEditTarget = undefined; + console.info("[DBX][executeTabSql:mongo-aggregate:done]", { + traceId, + collection: mongoCommand.collection, + database: currentDatabase, + rowCount: result.documents.length, + total: result.total, + elapsed: elapsed(), + }); + break; + } + case "getIndexes": { + console.info("[DBX][executeTabSql:mongo-indexes:start]", { traceId, collection: mongoCommand.collection, database: currentDatabase }); + const indexes = await api.listIndexes(tab.connectionId, currentDatabase, "", mongoCommand.collection); + allResults.push(markQueryResultRowsRaw(mongoIndexesToQueryResult(indexes, performance.now() - commandStartedAt))); + mongoEditTarget = undefined; + console.info("[DBX][executeTabSql:mongo-indexes:done]", { + traceId, + collection: mongoCommand.collection, + database: currentDatabase, + indexCount: indexes.length, + elapsed: elapsed(), + }); + break; + } + case "insert": + case "update": + case "delete": + case "createIndex": + case "dropIndex": + case "dropIndexes": { + if (options?.mongoSafety) { + const safety = evaluateMongoWriteSafety(mongoCommand, options.mongoSafety); + if (!safety.allowed) throw new Error(safety.reason); + } + console.info("[DBX][executeTabSql:mongo-write:start]", { + traceId, + database: currentDatabase, + kind: mongoCommand.kind, + collection: mongoCommand.collection, + }); + mongoEditTarget = undefined; + if (mongoCommand.kind === "insert") { + const result = await api.mongoInsertDocuments(tab.connectionId, currentDatabase, mongoCommand.collection, mongoCommand.docsJson); + allResults.push(markQueryResultRowsRaw(mongoWriteToQueryResult(result.affected_rows, performance.now() - commandStartedAt))); + } else if (mongoCommand.kind === "update") { + const result = await api.mongoUpdateDocuments(tab.connectionId, currentDatabase, mongoCommand.collection, mongoCommand.filter, mongoCommand.update, mongoCommand.many); + allResults.push(markQueryResultRowsRaw(mongoWriteToQueryResult(result.affected_rows, performance.now() - commandStartedAt))); + } else if (mongoCommand.kind === "createIndex") { + const result = await api.mongoCreateIndex(tab.connectionId, currentDatabase, mongoCommand.collection, mongoCommand.keys, mongoCommand.options); + allResults.push(markQueryResultRowsRaw(mongoCreateIndexToQueryResult(result.name, performance.now() - commandStartedAt))); + } else if (mongoCommand.kind === "dropIndex" || mongoCommand.kind === "dropIndexes") { + const result = await api.mongoDropIndexes(tab.connectionId, currentDatabase, mongoCommand.collection, mongoCommand.kind === "dropIndex" ? mongoCommand.index : mongoCommand.indexes, mongoCommand.kind === "dropIndex"); + allResults.push(markQueryResultRowsRaw(mongoDroppedIndexesToQueryResult(result.dropped_names, performance.now() - commandStartedAt))); + } else { + const result = await api.mongoDeleteDocuments(tab.connectionId, currentDatabase, mongoCommand.collection, mongoCommand.filter, mongoCommand.many); + allResults.push(markQueryResultRowsRaw(mongoWriteToQueryResult(result.affected_rows, performance.now() - commandStartedAt))); + } + console.info("[DBX][executeTabSql:mongo-write:done]", { + traceId, + database: currentDatabase, + kind: mongoCommand.kind, + collection: mongoCommand.collection, + elapsed: elapsed(), + }); + break; + } + case "use": { + currentDatabase = mongoCommand.database; + allResults.push(markQueryResultRowsRaw(mongoUseToQueryResult(currentDatabase, performance.now() - commandStartedAt))); + mongoEditTarget = undefined; + console.info("[DBX][executeTabSql:mongo-use:done]", { + traceId, + database: currentDatabase, + elapsed: elapsed(), + }); + break; + } + } + } catch (error: any) { + // Surface per-command failures inline and continue collecting results + // for the rest of the batch, matching the grouped-result UX. + allResults.push(toErrorResult(error)); + mongoEditTarget = undefined; + } + } + + console.info("[DBX][executeTabSql:mongo:done]", { + traceId, + database: currentDatabase, + commandCount: mongoCommands.length, + elapsed: elapsed(), + }); + + const current = tabs.value.find((t) => t.id === id); + if (current?.executionId === executionId) { + if (allResults.length > 1) { + // Open grouped output on the first non-error result when possible so + // mixed success/error batches land on the most useful table first. + const activeResultIndex = allResults.findIndex((result) => !result.columns.includes("Error")); + const resultIndex = activeResultIndex >= 0 ? activeResultIndex : 0; + current.results = allResults; + current.activeResultIndex = resultIndex; + current.result = allResults[resultIndex]; + } else { + current.results = undefined; + current.activeResultIndex = undefined; + current.result = allResults[0]; + } + touchResult(current); + current.queryAnalysis = undefined; + current.querySourceColumns = undefined; + current.queryEditabilityReason = undefined; + current.mongoEditTarget = mongoCommands.length === 1 ? mongoEditTarget : undefined; + current.tableMeta = undefined; + current.resultBaseSql = options?.resultBaseSql ?? sql; + current.resultSortedSql = options?.resultSortedSql; + syncDisplayedResultRun(current, options?.resultBaseSql ?? sql); + if (current.database !== currentDatabase) current.database = currentDatabase; + } + return; + } + if (tab.mode === "query") { const pagination = options?.pagination ?? { limit: settingsStore.editorSettings.pageSize, offset: 0 }; const plan = await api.prepareQueryPaginationExecutionPlan({ @@ -1709,269 +1902,6 @@ export const useQueryStore = defineStore("query", () => { pageLimit = options?.pagination?.limit ?? tableOpenPageLimit(); pageOffset = options?.pagination?.offset ?? 0; } - const mongoFind = conn?.db_type === "mongodb" ? parseMongoFindCommand(sql) : null; - if (mongoFind) { - await connStore.ensureConnected(tab.connectionId); - console.info("[DBX][executeTabSql:mongo-find:start]", { traceId, collection: mongoFind.collection }); - const result = await api.mongoFindDocuments(tab.connectionId, tab.database, mongoFind.collection, mongoFind.skip, mongoFind.limit, mongoFind.filter, mongoFind.projection, mongoFind.sort, executionId); - console.info("[DBX][executeTabSql:mongo-find:done]", { - traceId, - rowCount: result.documents.length, - total: result.total, - elapsed: elapsed(), - }); - const current = tabs.value.find((t) => t.id === id); - if (current?.executionId === executionId) { - current.results = undefined; - current.activeResultIndex = undefined; - current.result = markQueryResultRowsRaw(mongoDocumentsToQueryResult(result.documents, performance.now() - startedAt, result.total)); - touchResult(current); - current.queryAnalysis = undefined; - current.querySourceColumns = undefined; - current.queryEditabilityReason = undefined; - current.mongoEditTarget = current.result.columns.includes("_id") ? { collection: mongoFind.collection, idColumn: "_id" } : undefined; - current.tableMeta = undefined; - current.resultBaseSql = options?.resultBaseSql ?? sql; - current.resultSortedSql = options?.resultSortedSql; - syncDisplayedResultRun(current, options?.resultBaseSql ?? sql); - } - return; - } - const mongoVersion = conn?.db_type === "mongodb" ? parseMongoVersionCommand(sql) : null; - if (mongoVersion) { - await connStore.ensureConnected(tab.connectionId); - console.info("[DBX][executeTabSql:mongo-version:start]", { traceId }); - const version = await api.mongoServerVersion(tab.connectionId, tab.database, executionId); - console.info("[DBX][executeTabSql:mongo-version:done]", { - traceId, - version, - elapsed: elapsed(), - }); - const current = tabs.value.find((t) => t.id === id); - if (current?.executionId === executionId) { - current.results = undefined; - current.activeResultIndex = undefined; - current.result = markQueryResultRowsRaw(mongoVersionToQueryResult(version, performance.now() - startedAt)); - touchResult(current); - current.queryAnalysis = undefined; - current.querySourceColumns = undefined; - current.queryEditabilityReason = undefined; - current.mongoEditTarget = undefined; - current.tableMeta = undefined; - current.resultBaseSql = options?.resultBaseSql ?? sql; - current.resultSortedSql = options?.resultSortedSql; - syncDisplayedResultRun(current, options?.resultBaseSql ?? sql); - } - return; - } - const mongoCount = conn?.db_type === "mongodb" ? parseMongoCountDocumentsCommand(sql) : null; - if (mongoCount) { - await connStore.ensureConnected(tab.connectionId); - console.info("[DBX][executeTabSql:mongo-count:start]", { traceId, collection: mongoCount.collection }); - const result = await api.mongoFindDocuments(tab.connectionId, tab.database, mongoCount.collection, 0, 1, mongoCount.filter, undefined, undefined, executionId); - console.info("[DBX][executeTabSql:mongo-count:done]", { - traceId, - total: result.total, - elapsed: elapsed(), - }); - const current = tabs.value.find((t) => t.id === id); - if (current?.executionId === executionId) { - current.results = undefined; - current.activeResultIndex = undefined; - current.result = markQueryResultRowsRaw(mongoCountToQueryResult(result.total, performance.now() - startedAt)); - touchResult(current); - current.queryAnalysis = undefined; - current.querySourceColumns = undefined; - current.queryEditabilityReason = undefined; - current.mongoEditTarget = undefined; - current.tableMeta = undefined; - current.resultBaseSql = options?.resultBaseSql ?? sql; - current.resultSortedSql = options?.resultSortedSql; - syncDisplayedResultRun(current, options?.resultBaseSql ?? sql); - } - return; - } - - const mongoAggregate = conn?.db_type === "mongodb" ? parseMongoAggregateCommand(sql) : null; - if (mongoAggregate) { - if (options?.mongoSafety) { - const safety = evaluateMongoAggregateSafety(mongoAggregate, options.mongoSafety); - if (!safety.allowed) throw new Error(safety.reason); - } - await connStore.ensureConnected(tab.connectionId); - console.info("[DBX][executeTabSql:mongo-aggregate:start]", { traceId, collection: mongoAggregate.collection }); - const aggregateMaxRows = normalizeResultPageSize(pageLimit ?? options?.pagination?.limit ?? settingsStore.editorSettings.pageSize); - const result = await api.mongoAggregateDocuments(tab.connectionId, tab.database, mongoAggregate.collection, mongoAggregate.pipeline, aggregateMaxRows, executionId); - console.info("[DBX][executeTabSql:mongo-aggregate:done]", { - traceId, - rowCount: result.documents.length, - total: result.total, - elapsed: elapsed(), - }); - const current = tabs.value.find((t) => t.id === id); - if (current?.executionId === executionId) { - current.results = undefined; - current.activeResultIndex = undefined; - current.result = markQueryResultRowsRaw(mongoDocumentsToQueryResult(result.documents, performance.now() - startedAt, result.total)); - touchResult(current); - current.queryAnalysis = undefined; - current.querySourceColumns = undefined; - current.queryEditabilityReason = undefined; - current.mongoEditTarget = undefined; - current.tableMeta = undefined; - current.resultBaseSql = options?.resultBaseSql ?? sql; - current.resultSortedSql = options?.resultSortedSql; - syncDisplayedResultRun(current, options?.resultBaseSql ?? sql); - } - return; - } - - const mongoGetIndexes = conn?.db_type === "mongodb" ? parseMongoGetIndexesCommand(sql) : null; - if (mongoGetIndexes) { - await connStore.ensureConnected(tab.connectionId); - console.info("[DBX][executeTabSql:mongo-indexes:start]", { traceId, collection: mongoGetIndexes.collection }); - const indexes = await api.listIndexes(tab.connectionId, tab.database, "", mongoGetIndexes.collection); - console.info("[DBX][executeTabSql:mongo-indexes:done]", { - traceId, - indexCount: indexes.length, - elapsed: elapsed(), - }); - const current = tabs.value.find((t) => t.id === id); - if (current?.executionId === executionId) { - current.results = undefined; - current.activeResultIndex = undefined; - current.result = markQueryResultRowsRaw(mongoIndexesToQueryResult(indexes, performance.now() - startedAt)); - touchResult(current); - current.queryAnalysis = undefined; - current.querySourceColumns = undefined; - current.queryEditabilityReason = undefined; - current.mongoEditTarget = undefined; - current.tableMeta = undefined; - current.resultBaseSql = options?.resultBaseSql ?? sql; - current.resultSortedSql = options?.resultSortedSql; - syncDisplayedResultRun(current, options?.resultBaseSql ?? sql); - } - return; - } - - const mongoWrite = conn?.db_type === "mongodb" ? parseMongoWriteCommand(sql) : null; - if (mongoWrite) { - if (options?.mongoSafety) { - const safety = evaluateMongoWriteSafety(mongoWrite, options.mongoSafety); - if (!safety.allowed) throw new Error(safety.reason); - } - await connStore.ensureConnected(tab.connectionId); - console.info("[DBX][executeTabSql:mongo-write:start]", { - traceId, - kind: mongoWrite.kind, - collection: mongoWrite.collection, - }); - let affectedRows = 0; - if (mongoWrite.kind === "insert") { - const result = await api.mongoInsertDocuments(tab.connectionId, tab.database, mongoWrite.collection, mongoWrite.docsJson); - affectedRows = result.affected_rows; - } else if (mongoWrite.kind === "update") { - const result = await api.mongoUpdateDocuments(tab.connectionId, tab.database, mongoWrite.collection, mongoWrite.filter, mongoWrite.update, mongoWrite.many); - affectedRows = result.affected_rows; - } else if (mongoWrite.kind === "createIndex") { - const result = await api.mongoCreateIndex(tab.connectionId, tab.database, mongoWrite.collection, mongoWrite.keys, mongoWrite.options); - console.info("[DBX][executeTabSql:mongo-write:done]", { - traceId, - indexName: result.name, - elapsed: elapsed(), - }); - const current = tabs.value.find((t) => t.id === id); - if (current?.executionId === executionId) { - current.results = undefined; - current.activeResultIndex = undefined; - current.result = markQueryResultRowsRaw(mongoCreateIndexToQueryResult(result.name, performance.now() - startedAt)); - touchResult(current); - current.queryAnalysis = undefined; - current.querySourceColumns = undefined; - current.queryEditabilityReason = undefined; - current.mongoEditTarget = undefined; - current.tableMeta = undefined; - current.resultBaseSql = options?.resultBaseSql ?? sql; - current.resultSortedSql = options?.resultSortedSql; - syncDisplayedResultRun(current, options?.resultBaseSql ?? sql); - } - return; - } else if (mongoWrite.kind === "dropIndex" || mongoWrite.kind === "dropIndexes") { - const result = await api.mongoDropIndexes(tab.connectionId, tab.database, mongoWrite.collection, mongoWrite.kind === "dropIndex" ? mongoWrite.index : mongoWrite.indexes, mongoWrite.kind === "dropIndex"); - console.info("[DBX][executeTabSql:mongo-write:done]", { - traceId, - droppedNames: result.dropped_names, - elapsed: elapsed(), - }); - const current = tabs.value.find((t) => t.id === id); - if (current?.executionId === executionId) { - current.results = undefined; - current.activeResultIndex = undefined; - current.result = markQueryResultRowsRaw(mongoDroppedIndexesToQueryResult(result.dropped_names, performance.now() - startedAt)); - touchResult(current); - current.queryAnalysis = undefined; - current.querySourceColumns = undefined; - current.queryEditabilityReason = undefined; - current.mongoEditTarget = undefined; - current.tableMeta = undefined; - current.resultBaseSql = options?.resultBaseSql ?? sql; - current.resultSortedSql = options?.resultSortedSql; - syncDisplayedResultRun(current, options?.resultBaseSql ?? sql); - } - return; - } else { - const result = await api.mongoDeleteDocuments(tab.connectionId, tab.database, mongoWrite.collection, mongoWrite.filter, mongoWrite.many); - affectedRows = result.affected_rows; - } - console.info("[DBX][executeTabSql:mongo-write:done]", { - traceId, - affectedRows, - elapsed: elapsed(), - }); - const current = tabs.value.find((t) => t.id === id); - if (current?.executionId === executionId) { - current.results = undefined; - current.activeResultIndex = undefined; - current.result = markQueryResultRowsRaw(mongoWriteToQueryResult(affectedRows, performance.now() - startedAt)); - touchResult(current); - current.queryAnalysis = undefined; - current.querySourceColumns = undefined; - current.queryEditabilityReason = undefined; - current.mongoEditTarget = undefined; - current.tableMeta = undefined; - current.resultBaseSql = options?.resultBaseSql ?? sql; - current.resultSortedSql = options?.resultSortedSql; - syncDisplayedResultRun(current, options?.resultBaseSql ?? sql); - } - return; - } - - const mongoUse = conn?.db_type === "mongodb" ? parseMongoUseCommand(sql) : null; - if (mongoUse) { - console.info("[DBX][executeTabSql:mongo-use:start]", { traceId, database: mongoUse.database }); - const current = tabs.value.find((t) => t.id === id); - if (current?.executionId === executionId) { - current.database = mongoUse.database; - current.results = undefined; - current.activeResultIndex = undefined; - current.result = markQueryResultRowsRaw(mongoUseToQueryResult(mongoUse.database, performance.now() - startedAt)); - touchResult(current); - current.queryAnalysis = undefined; - current.querySourceColumns = undefined; - current.queryEditabilityReason = undefined; - current.mongoEditTarget = undefined; - current.tableMeta = undefined; - current.resultBaseSql = options?.resultBaseSql ?? sql; - current.resultSortedSql = options?.resultSortedSql; - syncDisplayedResultRun(current, options?.resultBaseSql ?? sql); - } - console.info("[DBX][executeTabSql:mongo-use:done]", { - traceId, - database: mongoUse.database, - elapsed: elapsed(), - }); - return; - } const executionSchema = connectionUsesSchemaExecutionContext(conn) ? tab.schema || tab.database : tab.mode === "data" || connectionUsesDatabaseObjectTreeMode(conn) ? undefined : tab.schema; const frontendTimeoutSecs = Math.max(queryTimeoutSecs * 2, 60); diff --git a/packages/app-tests/mongoShellCommand.test.ts b/packages/app-tests/mongoShellCommand.test.ts index ba7b8b7d39..407d25a479 100644 --- a/packages/app-tests/mongoShellCommand.test.ts +++ b/packages/app-tests/mongoShellCommand.test.ts @@ -8,11 +8,14 @@ import { mongoDocumentsToQueryResult, mongoIndexesToQueryResult, parseMongoAggregateCommand, + parseMongoCommand, parseMongoCountDocumentsCommand, parseMongoFindCommand, parseMongoGetIndexesCommand, parseMongoVersionCommand, parseMongoWriteCommand, + splitMongoCommands, + splitMongoCommandRanges, } from "../../apps/desktop/src/lib/mongoShellCommand.ts"; import { buildMongoUpdateDocument as buildMongoDocumentUpdate, formatMongoShellLiteral as formatMongoDocumentShellLiteral } from "../../apps/desktop/src/lib/mongoDocumentValues.ts"; @@ -91,6 +94,21 @@ test("parseMongoVersionCommand parses db.version", () => { assert.equal(parseMongoVersionCommand("db.jobs.version()"), null); }); +test("parseMongoCommand normalizes outer comments around a command", () => { + const parsed = parseMongoCommand(` + // current database + use accounting; + // keep working here + `); + assert.deepEqual(parsed, { + text: "use accounting;", + command: { + kind: "use", + database: "accounting", + }, + }); +}); + test("parseMongoWriteCommand accepts unquoted insert and update commands", () => { assert.deepEqual(parseMongoWriteCommand("db.products.insertOne({name: 'demo', price: 1})"), { kind: "insert", @@ -211,6 +229,69 @@ test("parseMongoGetIndexesCommand parses collection index commands", () => { assert.equal(parseMongoGetIndexesCommand("db.web_log.getIndexes({})"), null); }); +test("splitMongoCommands keeps semicolon-separated mongo commands in order", () => { + const commands = splitMongoCommands(` + db.users.insertOne({ name: "A" }); + db.users.insertOne({ name: "B" }); + `); + assert.deepEqual( + commands.map(({ text, command }) => ({ kind: command.kind, text })), + [ + { kind: "insert", text: 'db.users.insertOne({ name: "A" })' }, + { kind: "insert", text: 'db.users.insertOne({ name: "B" })' }, + ], + ); +}); + +test("splitMongoCommands splits top-level line starts without semicolons", () => { + const commands = splitMongoCommands(` + use accounting + db.getCollection("entries") + .find({ status: "open" }) + .limit(5) + `); + assert.deepEqual( + commands.map(({ text, command }) => ({ kind: command.kind, text })), + [ + { kind: "use", text: "use accounting" }, + { kind: "find", text: 'db.getCollection("entries")\n .find({ status: "open" })\n .limit(5)' }, + ], + ); +}); + +test("splitMongoCommandRanges preserve document offsets for newline-separated commands", () => { + const source = ` + use accounting + db.getCollection("entries") + .find({ status: "open" }) + .limit(5) + `; + const commands = splitMongoCommandRanges(source); + + assert.deepEqual( + commands.map(({ from, to, text, command }) => ({ + from, + to, + text, + kind: command.kind, + })), + [ + { + from: source.indexOf("use accounting"), + to: source.indexOf("use accounting") + "use accounting".length, + text: "use accounting", + kind: "use", + }, + { + from: source.indexOf('db.getCollection("entries")'), + to: source.indexOf(' .limit(5)') + " .limit(5)".length, + text: 'db.getCollection("entries")\n .find({ status: "open" })\n .limit(5)', + kind: "find", + }, + ], + ); +}); + test("evaluateMongoAggregateSafety blocks write stages unless MCP write flags allow them", () => { const out = parseMongoAggregateCommand('db.products.aggregate([{"$out":"products_copy"}])'); assert.ok(out); diff --git a/packages/app-tests/queryStore.test.ts b/packages/app-tests/queryStore.test.ts index 266298f5d1..510e94b928 100644 --- a/packages/app-tests/queryStore.test.ts +++ b/packages/app-tests/queryStore.test.ts @@ -2120,6 +2120,189 @@ test("mongo dropIndexes execution returns dropped index names", async () => { } }); +test("mongo multi-command execution runs writes sequentially and keeps grouped results", async () => { + const restoreStorage = installMemoryStorage(); + setActivePinia(createPinia()); + const connectionStore = useConnectionStore(); + const store = useQueryStore(); + const originalFetch = globalThis.fetch; + const insertBodies: any[] = []; + + connectionStore.addEphemeralConnection({ + ...conn("mongo-1"), + db_type: "mongodb", + port: 27017, + }); + + globalThis.fetch = withConnectionHealthMock(async (input, init) => { + const url = String(input); + if (url === "/api/mongo/insert-documents") { + insertBodies.push(JSON.parse(String(init?.body ?? "{}"))); + return new Response(JSON.stringify({ affected_rows: 1 }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response("unexpected request", { status: 500 }); + }); + + try { + const tabId = store.createTab("mongo-1", "accounting", "Query", "query", ""); + await store.executeTabSql( + tabId, + ` + db.users.insertOne({ name: "Ada" }); + db.users.insertOne({ name: "Grace" }); + `, + ); + const tab = store.tabs.find((item) => item.id === tabId); + + assert.equal(insertBodies.length, 2); + assert.deepEqual( + insertBodies.map((body) => ({ database: body.database, collection: body.collection, docsJson: body.docsJson })), + [ + { database: "accounting", collection: "users", docsJson: '{ "name": "Ada" }' }, + { database: "accounting", collection: "users", docsJson: '{ "name": "Grace" }' }, + ], + ); + assert.equal(tab?.results?.length, 2); + assert.equal(tab?.activeResultIndex, 0); + assert.equal(tab?.result?.affected_rows, 1); + } finally { + globalThis.fetch = originalFetch; + restoreStorage(); + } +}); + +test("mongo multi-command execution reconnects before running commands", async () => { + const restoreStorage = installMemoryStorage(); + setActivePinia(createPinia()); + const connectionStore = useConnectionStore(); + const store = useQueryStore(); + const originalFetch = globalThis.fetch; + const requests: string[] = []; + + connectionStore.addEphemeralConnection({ + ...conn("mongo-1"), + db_type: "mongodb", + port: 27017, + }); + connectionStore.connectedIds.delete("mongo-1"); + + globalThis.fetch = async (input, init) => { + const url = String(input); + if (url === "/api/connection/connect") { + requests.push(url); + return new Response(JSON.stringify("connected"), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + if (url === "/api/mongo/insert-documents") { + requests.push(url); + const body = JSON.parse(String(init?.body ?? "{}")); + assert.equal(body.database, "accounting"); + return new Response(JSON.stringify({ affected_rows: 1 }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response("unexpected request", { status: 500 }); + }; + + try { + const tabId = store.createTab("mongo-1", "accounting", "Query", "query", ""); + await store.executeTabSql(tabId, 'db.users.insertOne({ name: "Ada" })'); + + assert.deepEqual(requests, ["/api/connection/connect", "/api/mongo/insert-documents"]); + } finally { + globalThis.fetch = originalFetch; + restoreStorage(); + } +}); + +test("mongo multi-command execution applies use database before later commands", async () => { + const restoreStorage = installMemoryStorage(); + setActivePinia(createPinia()); + const connectionStore = useConnectionStore(); + const store = useQueryStore(); + const originalFetch = globalThis.fetch; + const insertBodies: any[] = []; + + connectionStore.addEphemeralConnection({ + ...conn("mongo-1"), + db_type: "mongodb", + port: 27017, + }); + + globalThis.fetch = withConnectionHealthMock(async (input, init) => { + const url = String(input); + if (url === "/api/mongo/insert-documents") { + insertBodies.push(JSON.parse(String(init?.body ?? "{}"))); + return new Response(JSON.stringify({ affected_rows: 1 }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response("unexpected request", { status: 500 }); + }); + + try { + const tabId = store.createTab("mongo-1", "accounting", "Query", "query", ""); + await store.executeTabSql( + tabId, + ` + use archive + db.users.insertOne({ name: "Ada" }) + `, + ); + const tab = store.tabs.find((item) => item.id === tabId); + + assert.equal(insertBodies.length, 1); + assert.equal(insertBodies[0]?.database, "archive"); + assert.equal(tab?.database, "archive"); + assert.equal(tab?.results?.length, 2); + assert.deepEqual(tab?.results?.[0]?.rows, [["switched to db archive"]]); + } finally { + globalThis.fetch = originalFetch; + restoreStorage(); + } +}); + +test("mongo use-only execution updates the tab without reconnecting", async () => { + const restoreStorage = installMemoryStorage(); + setActivePinia(createPinia()); + const connectionStore = useConnectionStore(); + const store = useQueryStore(); + const originalFetch = globalThis.fetch; + const requests: string[] = []; + + connectionStore.addEphemeralConnection({ + ...conn("mongo-1"), + db_type: "mongodb", + port: 27017, + }); + connectionStore.connectedIds.delete("mongo-1"); + + globalThis.fetch = async (input) => { + requests.push(String(input)); + return new Response("unexpected request", { status: 500 }); + }; + + try { + const tabId = store.createTab("mongo-1", "accounting", "Query", "query", ""); + await store.executeTabSql(tabId, "use archive"); + const tab = store.tabs.find((item) => item.id === tabId); + + assert.deepEqual(requests, []); + assert.equal(tab?.database, "archive"); + assert.deepEqual(tab?.result?.rows, [["switched to db archive"]]); + } finally { + globalThis.fetch = originalFetch; + restoreStorage(); + } +}); + test("table data export fetches every filtered page", async () => { const restoreStorage = installMemoryStorage(); setActivePinia(createPinia()); diff --git a/packages/app-tests/sqlExecutionTarget.test.ts b/packages/app-tests/sqlExecutionTarget.test.ts new file mode 100644 index 0000000000..61d723e0be --- /dev/null +++ b/packages/app-tests/sqlExecutionTarget.test.ts @@ -0,0 +1,42 @@ +import { strict as assert } from "node:assert"; +import { beforeEach, test, vi } from "vitest"; + +const apiMock = vi.hoisted(() => ({ + findStatementAtCursor: vi.fn(), +})); + +vi.mock("@/lib/api", () => apiMock); + +const { resolveExecutableSqlWithBackend } = await import("../../apps/desktop/src/lib/sqlExecutionTarget.ts"); + +beforeEach(() => { + apiMock.findStatementAtCursor.mockReset(); +}); + +test("mongodb backend resolution keeps the full text when nothing is selected", async () => { + apiMock.findStatementAtCursor.mockResolvedValue("db.users.insertOne({ name: 'ignored' })"); + + const fullSql = "db.users.insertOne({ name: 'Ada' });\ndb.users.insertOne({ name: 'Grace' });"; + const resolved = await resolveExecutableSqlWithBackend(fullSql, "", { + mode: "current", + cursorPos: fullSql.indexOf("Grace"), + databaseType: "mongodb", + }); + + assert.equal(resolved, fullSql); + assert.equal(apiMock.findStatementAtCursor.mock.calls.length, 0); +}); + +test("non-mongodb backend resolution still asks the backend for the current statement", async () => { + apiMock.findStatementAtCursor.mockResolvedValue("SELECT 2"); + + const fullSql = "SELECT 1;\nSELECT 2;"; + const resolved = await resolveExecutableSqlWithBackend(fullSql, "", { + mode: "current", + cursorPos: fullSql.indexOf("2"), + databaseType: "postgres", + }); + + assert.equal(resolved, "SELECT 2"); + assert.deepEqual(apiMock.findStatementAtCursor.mock.calls[0], [fullSql, fullSql.indexOf("2"), "postgres"]); +});