From 28c219ff03d9700b2a3741688e9e7686c605edcc Mon Sep 17 00:00:00 2001 From: Loc Nguyen <113083148+thailoc-dev@users.noreply.github.com> Date: Fri, 18 Sep 2026 17:03:04 +0700 Subject: [PATCH] feat(mongodb): show shell command diagnostics while editing --- .../src/components/editor/QueryEditor.vue | 13 +- .../src/lib/mongo/mongoShellCommand.ts | 8 +- .../src/lib/mongo/mongoSyntaxDiagnostics.ts | 187 ++++++++++++++++++ .../app-tests/mongoSyntaxDiagnostics.test.ts | 130 ++++++++++++ 4 files changed, 331 insertions(+), 7 deletions(-) create mode 100644 apps/desktop/src/lib/mongo/mongoSyntaxDiagnostics.ts create mode 100644 packages/app-tests/mongoSyntaxDiagnostics.test.ts diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index 84ccda0be5..5a86ee5d04 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -219,6 +219,7 @@ import { } from "@/lib/sql/semantic/diagnostics"; import { resolveSqlDialectId, sqlReferenceAnalysisDialectFor } from "@/lib/sql/semantic/dialect"; import { buildRedisSyntaxDiagnostics, shouldRunRedisDiagnostics } from "@/lib/redis/redisSyntaxDiagnostics"; +import { buildMongoSyntaxDiagnostics } from "@/lib/mongo/mongoSyntaxDiagnostics"; import { buildRedisCompletionItemsFromContext, getRedisCompletionContext, getRedisCompletionResultValidFor, shouldAutoOpenRedisCompletion, takesKeyArgument, type RedisCompletionItem } from "@/lib/redis/redisCompletion"; import type { SqlCompletionColumn, SqlCompletionContext, SqlCompletionForeignKey, SqlCompletionItem, SqlCompletionObject, SqlCompletionReferencedTable, SqlCompletionTable } from "@/lib/sql/sqlCompletion"; import type { CompletionAssistantObjectKind, ColumnInfo, DatabaseType, IndexInfo, SqlReferenceAnalysis, SqlServerCompletionContext, SqlTableReference, SqlTextSpan } from "@/types/database"; @@ -3680,7 +3681,7 @@ function invalidateSemanticDiagnosticsForDocumentChange() { } function shouldSkipSqlSemanticDiagnostics() { - return props.databaseType === "victoriametrics" || (props.databaseType !== "redis" && !settingsStore.editorSettings.sqlSemanticDiagnosticsEnabled); + return props.databaseType === "victoriametrics" || (props.databaseType !== "redis" && props.databaseType !== "mongodb" && !settingsStore.editorSettings.sqlSemanticDiagnosticsEnabled); } function rangesOverlap(left: { from: number; to: number }, right: { from: number; to: number }): boolean { @@ -3822,10 +3823,16 @@ async function refreshSemanticDiagnostics(options: { preserveOutsideRanges?: boo setSemanticDiagnostics([]); return; } - if (props.databaseType === "mongodb" || props.databaseType === "elasticsearch" || props.databaseType === "easysearch" || props.databaseType === "meilisearch" || props.databaseType === "victoriametrics") { + if (props.databaseType === "elasticsearch" || props.databaseType === "easysearch" || props.databaseType === "meilisearch" || props.databaseType === "victoriametrics") { setSemanticDiagnostics([]); return; } + if (props.databaseType === "mongodb") { + // Shell commands have no SQL semantics; surface the parser's own diagnosis instead of waiting for Run. + const cursor = currentView.state.selection.main.head; + setSemanticDiagnostics(buildMongoSyntaxDiagnostics(sql, cursor)); + return; + } if (props.databaseType === "redis") { // Redis has no SQL semantics; run command-name / arity / quote / danger checks instead. if (!shouldRunRedisDiagnostics(sql, currentView.state.selection.main.head)) { @@ -7830,7 +7837,7 @@ watch( watch( () => settingsStore.editorSettings.sqlSemanticDiagnosticsEnabled, (enabled) => { - if (props.databaseType === "redis" || props.databaseType === "victoriametrics") return; + if (props.databaseType === "redis" || props.databaseType === "mongodb" || props.databaseType === "victoriametrics") return; if (!shouldSkipSqlSemanticDiagnostics() && enabled) { scheduleSemanticDiagnostics(0); return; diff --git a/apps/desktop/src/lib/mongo/mongoShellCommand.ts b/apps/desktop/src/lib/mongo/mongoShellCommand.ts index fef95f262c..46c2ac3ad0 100644 --- a/apps/desktop/src/lib/mongo/mongoShellCommand.ts +++ b/apps/desktop/src/lib/mongo/mongoShellCommand.ts @@ -1342,13 +1342,13 @@ function parseMethodArgs(source: string, methodCallIndex: number): string[] | nu return splitTopLevel(source.slice(openIndex + 1, closeIndex)); } -interface MongoTextRange { +export interface MongoTextRange { from: number; to: number; text: string; } -function splitMongoCommandTextRanges(input: string): MongoTextRange[] { +export function splitMongoCommandTextRanges(input: string): MongoTextRange[] { const commands: MongoTextRange[] = []; for (const segment of splitMongoSemicolonSeparatedSegments(input)) { const parsed = parseMongoCommand(segment.text); @@ -1856,7 +1856,7 @@ export function validateBulkWriteOptions(optionsJson: string): string | null { } /** Filters of every non-insert operation, for the safety checks. */ -function bulkWriteFilters(operationsJson: string): string[] { +export function bulkWriteFilters(operationsJson: string): string[] { try { const entries = JSON.parse(operationsJson) as Array>; return entries.flatMap((entry) => Object.values(entry)).flatMap((spec) => (isDocument(spec?.filter) ? [JSON.stringify(spec.filter)] : [])); @@ -1879,7 +1879,7 @@ function mongoWriteFilter(command: MongoWriteCommand): string | null { } } -function mongoFilterIsEffectivelyUnbounded(json: string): boolean { +export function mongoFilterIsEffectivelyUnbounded(json: string): boolean { const parsed = parseNormalizedJson(json); return !isRecord(parsed) || mongoFilterContainsOpaqueLogic(parsed) || mongoFilterObjectIsUnbounded(parsed); } diff --git a/apps/desktop/src/lib/mongo/mongoSyntaxDiagnostics.ts b/apps/desktop/src/lib/mongo/mongoSyntaxDiagnostics.ts new file mode 100644 index 0000000000..02bea01a27 --- /dev/null +++ b/apps/desktop/src/lib/mongo/mongoSyntaxDiagnostics.ts @@ -0,0 +1,187 @@ +/** + * MongoDB shell command diagnostics for the query editor. + * + * Mirrors `redisSyntaxDiagnostics.ts` so the editor's diagnostic pipeline + * (StateField + Decoration.mark) renders them with no extra wiring. The source is + * split into commands with the same splitter the executor uses, so an underline + * always corresponds to a statement that would fail or warrant care when run. + */ +import type { SqlTextSpan } from "@/types/database"; +import type { SqlSemanticDiagnostic } from "@/lib/sql/semantic/diagnostics"; +import { findMatchingParen, hasUnclosedMongoDelimiters, trimMongoOuterComments } from "@dbx-app/mongo-shell"; +import { bulkWriteFilters, describeMongoCommandParseFailure, mongoFilterIsEffectivelyUnbounded, parseMongoCommand, splitMongoCommandTextRanges, type MongoCommand, type MongoTextRange } from "@/lib/mongo/mongoShellCommand"; + +const UNCLOSED_MESSAGE = "MongoDB command has unclosed parentheses, brackets, braces, or strings."; + +/** + * `db..` / `db["…"].` / `db.getCollection("…").` / + * `db.getSiblingDB("…")..` / `db.` — the part worth underlining by default. + * Call forms come first so `.getCollection("x")` is consumed whole instead of stopping at the name. + */ +const COMMAND_HEAD = /^\s*db\s*(?:\.\s*(?:getCollection|getSiblingDB)\s*\([^)]*\)|\.\s*[A-Za-z_$][\w$]*|\[[^\]]*\])*(?:\s*\.\s*[A-Za-z_$][\w$]*)?/; + +export function buildMongoSyntaxDiagnostics(source: string, cursor = -1): SqlSemanticDiagnostic[] { + const diagnostics: SqlSemanticDiagnostic[] = []; + const segments = splitMongoCommandTextRanges(source); + const current = segmentAtCursor(segments, cursor, source.length); + + for (const segment of segments) { + const text = trimMongoOuterComments(segment.text).trim().replace(/;$/, "").trim(); + if (!text) continue; + + if (hasUnclosedMongoDelimiters(text)) { + // A command still being typed under the cursor is not an error yet. + if (segment === current) continue; + diagnostics.push({ span: spanFor(source, segment.from, segment.to), message: UNCLOSED_MESSAGE, severity: "error" }); + continue; + } + + const parsed = parseMongoCommand(segment.text); + if (!parsed) { + const message = describeMongoCommandParseFailure(segment.text); + diagnostics.push({ span: errorSpan(source, segment, message), severity: "error", message }); + continue; + } + + const warning = describeMongoWriteRisk(parsed.command); + if (warning) diagnostics.push({ span: headSpan(source, segment), severity: "warning", message: warning }); + } + + return diagnostics; +} + +/** + * Hold off while the cursor sits inside a command whose delimiters are still + * open — the user is mid-expression and everything after that point is noise. + * Mirrors the intent of `shouldRunRedisDiagnostics`. + */ +export function shouldRunMongoDiagnostics(source: string, cursor: number): boolean { + if (!source.trim()) return false; + const current = segmentAtCursor(splitMongoCommandTextRanges(source), cursor, source.length); + if (!current) return true; + const text = trimMongoOuterComments(current.text).trim(); + return !text || !hasUnclosedMongoDelimiters(text); +} + +/** The command the cursor is in, counting the whitespace after it up to the next command. */ +function segmentAtCursor(segments: readonly MongoTextRange[], cursor: number, sourceLength: number): MongoTextRange | undefined { + if (cursor < 0) return undefined; + return segments.find((segment, index) => cursor >= segment.from && cursor <= (segments[index + 1]?.from ?? sourceLength)); +} + +/* ------------------------------------------------------------------ * + * Warnings for commands that parse but deserve a second look + * ------------------------------------------------------------------ */ + +function describeMongoWriteRisk(command: MongoCommand): string | null { + switch (command.kind) { + case "update": + case "delete": { + if (!mongoFilterIsEffectivelyUnbounded(command.filter)) return null; + const verb = command.kind === "update" ? "update" : "delete"; + return command.many ? `This ${verb} has no effective filter and will ${verb} every document in ${command.collection}.` : `This ${verb} has no effective filter and will ${verb} an arbitrary document in ${command.collection}.`; + } + case "replace": + return mongoFilterIsEffectivelyUnbounded(command.filter) ? `This replace has no effective filter and will replace an arbitrary document in ${command.collection}.` : null; + case "findOneAndUpdate": + case "findOneAndReplace": + case "findOneAndDelete": { + if (!mongoFilterIsEffectivelyUnbounded(command.filter)) return null; + const verb = command.kind === "findOneAndUpdate" ? "update" : command.kind === "findOneAndReplace" ? "replace" : "delete"; + return `This ${command.kind} has no effective filter and will ${verb} an arbitrary document in ${command.collection}.`; + } + case "bulkWrite": + return bulkWriteFilters(command.operations).some(mongoFilterIsEffectivelyUnbounded) ? `This bulkWrite contains an operation with no effective filter, which can affect every document in ${command.collection}.` : null; + case "dropCollection": + return `This drops the ${command.collection} collection and all of its documents.`; + case "inDatabase": + // db.getSiblingDB("x").coll.deleteMany({}) is just as destructive as the unwrapped form. + return describeMongoWriteRisk(command.command); + default: + return null; + } +} + +/* ------------------------------------------------------------------ * + * Span placement + * ------------------------------------------------------------------ */ + +/** Underline the part the message is about when it can be found in the text; otherwise the command head. */ +function errorSpan(source: string, segment: MongoTextRange, message: string): SqlTextSpan { + const text = segment.text; + + const constructor = /Unsupported value (?:new )?([A-Za-z_$][\w$]*)\(\.\.\.\)/.exec(message); + if (constructor) { + const callIndex = indexOutsideStrings(text, new RegExp(`\\b${escapeRegExp(constructor[1]!)}\\s*\\(`)); + if (callIndex >= 0) { + const openIndex = text.indexOf("(", callIndex); + const closeIndex = findMatchingParen(text, openIndex); + return spanFor(source, segment.from + callIndex, segment.from + (closeIndex >= 0 ? closeIndex + 1 : openIndex + 1)); + } + } + + const operator = /must not contain update operators such as (\$[A-Za-z]+)/.exec(message); + if (operator) { + const keyIndex = indexOutsideStrings(text, new RegExp(escapeRegExp(operator[1]!))); + if (keyIndex >= 0) return spanFor(source, segment.from + keyIndex, segment.from + keyIndex + operator[1]!.length); + } + + const trailing = /Unexpected text after [^:]+: "(.+)"\.$/.exec(message); + if (trailing) { + const tailIndex = text.lastIndexOf(trailing[1]!.replace(/…$/, "")); + if (tailIndex >= 0) return spanFor(source, segment.from + tailIndex, segment.to); + } + + return headSpan(source, segment); +} + +function headSpan(source: string, segment: MongoTextRange): SqlTextSpan { + const text = segment.text; + const leading = text.length - text.trimStart().length; + const head = COMMAND_HEAD.exec(text); + const headEnd = head && head[0].trim() ? head[0].length : text.indexOf("\n", leading) === -1 ? text.length : text.indexOf("\n", leading); + return spanFor(source, segment.from + leading, segment.from + Math.max(headEnd, leading + 1)); +} + +/** 1-based line/column span for the absolute offsets [from, to). `end_column` is inclusive. */ +function spanFor(source: string, from: number, to: number): SqlTextSpan { + const start = lineColumnAt(source, from); + const end = lineColumnAt(source, Math.max(to - 1, from)); + return { start_line: start.line, start_column: start.column, end_line: end.line, end_column: end.column }; +} + +function lineColumnAt(source: string, offset: number): { line: number; column: number } { + let line = 1; + let lineStart = 0; + for (let index = 0; index < offset && index < source.length; index += 1) { + if (source[index] === "\n") { + line += 1; + lineStart = index + 1; + } + } + return { line, column: offset - lineStart + 1 }; +} + +function indexOutsideStrings(text: string, pattern: RegExp): number { + let quote: string | null = null; + let escaped = false; + for (let index = 0; index < text.length; index += 1) { + const char = text[index]!; + if (quote) { + if (escaped) escaped = false; + else if (char === "\\") escaped = true; + else if (char === quote) quote = null; + continue; + } + if (char === '"' || char === "'") { + quote = char; + continue; + } + if (pattern.test(text.slice(index)) && text.slice(index).search(pattern) === 0) return index; + } + return -1; +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} diff --git a/packages/app-tests/mongoSyntaxDiagnostics.test.ts b/packages/app-tests/mongoSyntaxDiagnostics.test.ts new file mode 100644 index 0000000000..0554cf7590 --- /dev/null +++ b/packages/app-tests/mongoSyntaxDiagnostics.test.ts @@ -0,0 +1,130 @@ +import { strict as assert } from "node:assert"; +import { readFileSync } from "node:fs"; +import { test } from "vitest"; +import { buildMongoSyntaxDiagnostics, shouldRunMongoDiagnostics } from "../../apps/desktop/src/lib/mongo/mongoSyntaxDiagnostics"; + +/** The exact text a diagnostic underlines, so span placement is asserted and not just line numbers. */ +function underlined(source: string, cursor = -1): Array<{ severity: string; text: string; message: string }> { + const lines = source.split("\n"); + return buildMongoSyntaxDiagnostics(source, cursor).map((diagnostic) => { + const { span } = diagnostic; + const text = + span.start_line === span.end_line ? lines[span.start_line - 1]!.slice(span.start_column - 1, span.end_column) : [lines[span.start_line - 1]!.slice(span.start_column - 1), ...lines.slice(span.start_line, span.end_line - 1), lines[span.end_line - 1]!.slice(0, span.end_column)].join("\n"); + return { severity: diagnostic.severity, text, message: diagnostic.message }; + }); +} + +test("valid commands and comments produce no diagnostics", () => { + for (const source of ["db.reports.find({a: 1})", 'db["my-coll"].updateOne({a: 1}, {$set: {b: 2}}, {upsert: true})', "// just a note", "db.a.find({});\ndb.b.countDocuments({})", ""]) { + assert.deepEqual(buildMongoSyntaxDiagnostics(source), [], source); + } +}); + +test("underlines the offending value constructor and names the argument", () => { + const [single] = underlined('db.reports.find({id: Foo("x")})'); + assert.equal(single?.severity, "error"); + assert.equal(single?.text, 'Foo("x")'); + assert.match(single!.message, /Unsupported value Foo\(\.\.\.\) in the filter argument of find\(\)/); + + // Works across lines and through `new`, and finds the call inside a nested document. + const [nested] = underlined("db.reports.updateOne(\n { phone: '+84' },\n { $set: { t: new Bar(1) } }\n)"); + assert.equal(nested?.text, "Bar(1)"); + assert.match(nested!.message, /update argument of updateOne\(\)/); +}); + +test("underlines the update operator in a replaceOne replacement", () => { + const [diagnostic] = underlined("db.reports.replaceOne({a: 1}, {$set: {b: 2}})"); + assert.equal(diagnostic?.text, "$set"); + assert.match(diagnostic!.message, /use updateOne\(\)/); +}); + +test("underlines the command head for unsupported methods and wrong argument shapes", () => { + const [unsupported] = underlined('db.reports.mapReduce("x")'); + assert.equal(unsupported?.text, "db.reports.mapReduce"); + assert.match(unsupported!.message, /mapReduce\(\) is not supported/); + + const [shape] = underlined("db.reports.insertOne({a: 1}, {writeConcern: {w: 1}})"); + assert.equal(shape?.text, "db.reports.insertOne"); + assert.equal(shape?.message, "insertOne() expects one document."); + + const [dbLevel] = underlined("db.adminCommand({ping: 1})"); + assert.equal(dbLevel?.text, "db.adminCommand"); +}); + +test("warns about writes with no effective filter and about dropping a collection", () => { + const [every] = underlined("db.reports.deleteMany({})"); + assert.equal(every?.severity, "warning"); + assert.equal(every?.text, "db.reports.deleteMany"); + assert.match(every!.message, /every document in reports/); + + const [arbitrary] = underlined("db.reports.updateOne({}, {$set: {a: 1}})"); + assert.match(arbitrary!.message, /an arbitrary document in reports/); + assert.match(underlined("db.reports.replaceOne({_id: {$exists: true}}, {a: 1})")[0]!.message, /replace .* arbitrary document/); + assert.match(underlined("db.reports.drop()")[0]!.message, /drops the reports collection/); + assert.equal(underlined('db.getCollection("my-coll").drop()')[0]?.text, 'db.getCollection("my-coll").drop'); + + assert.match(underlined("db.reports.bulkWrite([{ insertOne: { document: { a: 1 } } }, { deleteMany: { filter: {} } }])")[0]!.message, /bulkWrite contains an operation with no effective filter/); + + // Wrapping the command in getSiblingDB() does not hide it. + const [sibling] = underlined('db.getSiblingDB("archive").reports.deleteMany({})'); + assert.equal(sibling?.severity, "warning"); + assert.equal(sibling?.text, 'db.getSiblingDB("archive").reports.deleteMany'); + assert.match(sibling!.message, /every document in reports/); + + // A bounded write is not a warning. + assert.deepEqual(buildMongoSyntaxDiagnostics("db.reports.deleteMany({status: 'stale'})"), []); + assert.deepEqual(buildMongoSyntaxDiagnostics("db.reports.bulkWrite([{ deleteMany: { filter: { status: 'stale' } } }])"), []); + assert.deepEqual(buildMongoSyntaxDiagnostics('db.getSiblingDB("archive").reports.deleteMany({status: "stale"})'), []); +}); + +test("warns about find-and-modify commands with no effective filter", () => { + for (const [source, action] of [ + ["db.reports.findOneAndUpdate({}, {$set: {status: 'archived'}})", "update"], + ["db.reports.findOneAndReplace({}, {status: 'archived'})", "replace"], + ["db.reports.findOneAndDelete({})", "delete"], + ] as const) { + const [diagnostic] = underlined(source); + assert.equal(diagnostic?.severity, "warning", source); + assert.match(diagnostic!.message, new RegExp(`${action} an arbitrary document in reports`), source); + } + + for (const source of ["db.reports.findOneAndUpdate({_id: 1}, {$set: {status: 'archived'}})", "db.reports.findOneAndReplace({_id: 1}, {status: 'archived'})", "db.reports.findOneAndDelete({_id: 1})"]) { + assert.deepEqual(buildMongoSyntaxDiagnostics(source), [], source); + } +}); + +test("reports each statement separately", () => { + const diagnostics = underlined("db.a.find({});\ndb.b.foo();\ndb.c.find({})"); + assert.equal(diagnostics.length, 1); + assert.equal(diagnostics[0]?.text, "db.b.foo"); +}); + +test("does not flag a command the cursor is still typing", () => { + const typing = "db.reports.find({a: 1"; + assert.deepEqual(buildMongoSyntaxDiagnostics(typing, typing.length), []); + assert.deepEqual(buildMongoSyntaxDiagnostics(`${typing}\n`, typing.length + 1), []); + assert.equal(shouldRunMongoDiagnostics(typing, typing.length), false); + + // Once the cursor has left it, an unclosed command is an error. + assert.equal(shouldRunMongoDiagnostics("db.reports.find({a: 1})", 23), true); + const [left] = underlined(typing, -1); + assert.equal(left?.severity, "error"); + assert.match(left!.message, /unclosed/i); + assert.equal(shouldRunMongoDiagnostics("", 0), false); +}); + +test("keeps completed diagnostics while the cursor is in an unfinished command", () => { + const source = "db.a.deleteMany({});\ndb.b.find({"; + const [diagnostic] = underlined(source, source.length); + assert.equal(diagnostic?.severity, "warning"); + assert.equal(diagnostic?.text, "db.a.deleteMany"); + assert.match(diagnostic!.message, /every document in a/); + + const unfinished = underlined(source, 0).find((diagnostic) => diagnostic.severity === "error"); + assert.match(unfinished!.message, /unclosed/i); + + const queryEditorSource = readFileSync("apps/desktop/src/components/editor/QueryEditor.vue", "utf8"); + const mongoBranch = queryEditorSource.slice(queryEditorSource.indexOf('if (props.databaseType === "mongodb")'), queryEditorSource.indexOf('if (props.databaseType === "redis")')); + assert.doesNotMatch(mongoBranch, /shouldRunMongoDiagnostics/); + assert.match(mongoBranch, /setSemanticDiagnostics\(buildMongoSyntaxDiagnostics\(sql, cursor\)\)/); +});