diff --git a/apps/desktop/src/lib/sql/sqlExecutionTarget.ts b/apps/desktop/src/lib/sql/sqlExecutionTarget.ts index 789812adf..dc105b3cc 100644 --- a/apps/desktop/src/lib/sql/sqlExecutionTarget.ts +++ b/apps/desktop/src/lib/sql/sqlExecutionTarget.ts @@ -1,4 +1,5 @@ import * as api from "@/lib/backend/api"; +import { mongoCommandRangeAtCursor } from "@/lib/sql/sqlStatementRanges"; import type { DatabaseType } from "@/types/database"; export type ExecuteMode = "all" | "current"; @@ -52,10 +53,12 @@ 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?.databaseType === "mongodb") { + if (options.mode === "current" && options.cursorPos !== undefined) { + return mongoCommandRangeAtCursor(fullSql, options.cursorPos)?.sql ?? fullSql; + } + 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/sql/sqlStatementRanges.ts b/apps/desktop/src/lib/sql/sqlStatementRanges.ts index 155a542b2..96384ccc0 100644 --- a/apps/desktop/src/lib/sql/sqlStatementRanges.ts +++ b/apps/desktop/src/lib/sql/sqlStatementRanges.ts @@ -409,6 +409,26 @@ export function statementRangeAtCursor(sql: string, cursorPos: number, databaseT return null; } +export function mongoCommandRangeAtCursor(sql: string, cursorPos: number): SqlTextRange | null { + const pos = clampCursor(sql, cursorPos); + if (isCursorOnBlankLine(sql, pos)) return null; + + const commands = splitMongoCommandRanges(sql); + for (let index = 0; index < commands.length; index += 1) { + const command = commands[index]; + const range = { from: command.from, to: command.to, sql: command.text }; + + if (pos >= command.from && pos <= command.to) return range; + + const next = commands[index + 1]; + if (pos > command.to && (!next || pos < next.from) && isCursorInSameLineDelimiterGap(sql, command.to, pos)) return range; + + if (pos < command.from && sql.slice(pos, command.from).trim() === "" && isCursorOnStatementLine(sql, pos, command)) return range; + } + + return null; +} + function isCursorInSameLineDelimiterGap(sql: string, previousStatementEnd: number, cursorPos: number): boolean { if (cursorPos <= previousStatementEnd) return false; const between = sql.slice(previousStatementEnd, cursorPos); @@ -1450,7 +1470,7 @@ function isCursorOnBlankLine(sql: string, pos: number): boolean { return sql.slice(lineStart, lineEnd).trim() === ""; } -function isCursorOnStatementLine(sql: string, pos: number, statement: RawStatement): boolean { +function isCursorOnStatementLine(sql: string, pos: number, statement: Pick): boolean { const lineStart = sql.lastIndexOf("\n", pos - 1) + 1; let lineEnd = sql.indexOf("\n", pos); if (lineEnd === -1) lineEnd = sql.length; diff --git a/packages/app-tests/sqlExecutionTarget.test.ts b/packages/app-tests/sqlExecutionTarget.test.ts index af9dc8a2a..1fbe8ae63 100644 --- a/packages/app-tests/sqlExecutionTarget.test.ts +++ b/packages/app-tests/sqlExecutionTarget.test.ts @@ -13,20 +13,45 @@ beforeEach(() => { apiMock.findStatementAtCursor.mockReset(); }); -test("mongodb backend resolution keeps the full text when nothing is selected", async () => { +test("mongodb backend resolution uses the current command when configured", async () => { apiMock.findStatementAtCursor.mockResolvedValue("db.users.insertOne({ name: 'ignored' })"); - const fullSql = "db.users.insertOne({ name: 'Ada' });\ndb.users.insertOne({ name: 'Grace' });"; + const fullSql = 'db.users.find({ name: "Ada" });\ndb.users.find({ name: "Grace" });\ndb.users.find({ name: "Linus" });'; const resolved = await resolveExecutableSqlWithBackend(fullSql, "", { mode: "current", cursorPos: fullSql.indexOf("Grace"), databaseType: "mongodb", }); + assert.equal(resolved, 'db.users.find({ name: "Grace" })'); + assert.equal(apiMock.findStatementAtCursor.mock.calls.length, 0); +}); + +test("mongodb backend resolution keeps the full text in all mode", async () => { + const fullSql = 'db.users.find({ name: "Ada" });\ndb.users.find({ name: "Grace" });'; + const resolved = await resolveExecutableSqlWithBackend(fullSql, "", { + mode: "all", + cursorPos: fullSql.indexOf("Grace"), + databaseType: "mongodb", + }); + assert.equal(resolved, fullSql); assert.equal(apiMock.findStatementAtCursor.mock.calls.length, 0); }); +test("mongodb backend resolution prefers the manual selection", async () => { + const fullSql = 'db.users.find({ name: "Ada" });\ndb.users.find({ name: "Grace" });'; + const selectedSql = 'db.users.find({ name: "Ada" })'; + const resolved = await resolveExecutableSqlWithBackend(fullSql, selectedSql, { + mode: "current", + cursorPos: fullSql.indexOf("Grace"), + databaseType: "mongodb", + }); + + assert.equal(resolved, selectedSql); + 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");