diff --git a/apps/desktop/src/lib/mongo/mongoShellCommand.ts b/apps/desktop/src/lib/mongo/mongoShellCommand.ts index 6c2eaed95a..8aadbecc4b 100644 --- a/apps/desktop/src/lib/mongo/mongoShellCommand.ts +++ b/apps/desktop/src/lib/mongo/mongoShellCommand.ts @@ -2,7 +2,7 @@ import type { QueryResult } from "@/types/database"; import { mongoDocumentIdForGrid } from "@/lib/mongo/mongoDocumentValues"; import { chainedMethodCallPattern, - describeMongoCommandParseFailure, + describeMongoCommandParseFailure as describeMongoCommandParseFailureBasic, findChainedMethodCallIndex, findMatchingParen, MONGO_SHELL_COMMAND_HINT, @@ -16,7 +16,172 @@ import { } from "@dbx-app/mongo-shell"; export type { MongoAggregateCommand }; -export { describeMongoCommandParseFailure, MONGO_SHELL_COMMAND_HINT, parseMongoAggregateCommand, quoteUnquotedObjectKeys }; +export { MONGO_SHELL_COMMAND_HINT, parseMongoAggregateCommand, quoteUnquotedObjectKeys }; + +/* ------------------------------------------------------------------ * + * Parse-failure diagnostics + * + * When no parser accepts a command, say what was wrong with it rather than + * repeating the generic list of supported commands. The shared package only + * diagnoses aggregate-shaped input; this layer knows every method the editor + * supports, so it can name an unsupported method, an unsupported value + * constructor, or the argument shape a known method expects. + * ------------------------------------------------------------------ */ + +interface MongoMethodShape { + /** What the method takes, in prose, for "expects ..." messages. */ + expects: string; + /** Argument roles by position, for "the filter argument" wording. */ + roles: string[]; +} + +const COLLECTION_METHOD_SHAPES: Record = { + find: { expects: "an optional filter and an optional projection", roles: ["filter", "projection"] }, + findOne: { expects: "an optional filter, an optional projection, and optional options", roles: ["filter", "projection", "options"] }, + count: { expects: "an optional filter", roles: ["filter"] }, + countDocuments: { expects: "an optional filter", roles: ["filter"] }, + distinct: { expects: "a field name and an optional filter", roles: ["field", "filter"] }, + insert: { expects: "one document or an array of documents", roles: ["document"] }, + insertOne: { expects: "one document", roles: ["document"] }, + insertMany: { expects: "an array of documents", roles: ["documents"] }, + update: { expects: "a filter, an update, and optional options", roles: ["filter", "update", "options"] }, + updateOne: { expects: "a filter, an update, and optional options", roles: ["filter", "update", "options"] }, + updateMany: { expects: "a filter, an update, and optional options", roles: ["filter", "update", "options"] }, + deleteOne: { expects: "a filter", roles: ["filter"] }, + deleteMany: { expects: "a filter", roles: ["filter"] }, + findOneAndUpdate: { expects: "a filter, an update, and optional options", roles: ["filter", "update", "options"] }, + findOneAndReplace: { expects: "a filter, a replacement document, and optional options", roles: ["filter", "replacement", "options"] }, + findOneAndDelete: { expects: "a filter and optional options", roles: ["filter", "options"] }, + createIndex: { expects: "an index keys document and optional options", roles: ["keys", "options"] }, + dropIndex: { expects: "an index name or keys document", roles: ["index"] }, + dropIndexes: { expects: "no arguments, or an index name or list of names", roles: ["index"] }, + getIndexes: { expects: "no arguments", roles: [] }, + drop: { expects: "no arguments", roles: [] }, + stats: { expects: "an optional scale", roles: ["scale"] }, + dataSize: { expects: "no arguments", roles: [] }, + storageSize: { expects: "no arguments", roles: [] }, + totalIndexSize: { expects: "no arguments", roles: [] }, +}; + +const SUPPORTED_COLLECTION_METHODS = [ + "find", + "findOne", + "aggregate", + "count", + "countDocuments", + "distinct", + "insertOne", + "insertMany", + "updateOne", + "updateMany", + "deleteOne", + "deleteMany", + "findOneAndUpdate", + "findOneAndReplace", + "findOneAndDelete", + "getIndexes", + "createIndex", + "dropIndex", + "dropIndexes", + "drop", + "stats", +]; + +/** Database-level methods with a supported equivalent worth pointing at. */ +const DATABASE_METHOD_HINTS: Record = { + getSiblingDB: "switch databases with `use ` and then run the command against db.", + stats: "use db.runCommand({ dbStats: 1 })", + serverStatus: "use db.runCommand({ serverStatus: 1 })", + adminCommand: "use db.runCommand({ ... })", + getCollectionNames: "collections are listed in the sidebar", + createCollection: 'collections are created on first insert, or use db.runCommand({ create: "name" })', +}; + +const DATABASE_METHOD_SHAPES: Record = { + version: { expects: "no arguments", roles: [] }, + createUser: { expects: "a user document and optional write concern", roles: ["user", "writeConcern"] }, + runCommand: { expects: "one command document", roles: ["command"] }, +}; + +const SUPPORTED_DATABASE_METHODS = ["version", "createUser", "runCommand", "getCollection"]; + +const SUPPORTED_VALUE_CONSTRUCTORS = ["ObjectId", "ISODate", "new Date", "NumberLong", "NumberInt", "NumberDecimal", "UUID", "BinData", "Timestamp", "MinKey", "MaxKey"]; + +const COMMAND_SHAPE = /^db\s*(?:\.\s*(?[A-Za-z_$][\w$]*)|\[\s*(["'])(?.*?)\2\s*\]|\.\s*getCollection\s*\(\s*(["'])(?.*?)\4\s*\))?\s*\.\s*(?[A-Za-z_$][\w$]*)\s*\(/; + +export function describeMongoCommandParseFailure(input: string): string { + const basic = describeMongoCommandParseFailureBasic(input); + if (basic !== MONGO_SHELL_COMMAND_HINT) return basic; + const source = trimMongoOuterComments(input).trim().replace(/;$/, "").trim(); + return diagnoseMongoCommand(source) ?? basic; +} + +function diagnoseMongoCommand(source: string): string | null { + if (/^show\s+(collections|tables)\b/i.test(source)) { + return "show collections is not supported here; collections are listed in the sidebar. Only show dbs is supported."; + } + + const shape = COMMAND_SHAPE.exec(source); + if (!shape?.groups) return null; + const { method } = shape.groups; + if (!method) return null; + const isDatabaseLevel = shape.groups.collection === undefined && shape.groups.bracket === undefined && shape.groups.named === undefined; + + const shapeSpec = isDatabaseLevel ? DATABASE_METHOD_SHAPES[method] : COLLECTION_METHOD_SHAPES[method]; + if (!shapeSpec) { + if (isDatabaseLevel && SUPPORTED_DATABASE_METHODS.includes(method)) return null; + if (isDatabaseLevel) { + const hint = DATABASE_METHOD_HINTS[method]; + return `db.${method}() is not supported${hint ? `; ${hint}` : ""}. Supported database commands: ${SUPPORTED_DATABASE_METHODS.map((name) => `db.${name}()`).join(", ")}.`; + } + return `Collection method ${method}() is not supported. Supported collection methods: ${SUPPORTED_COLLECTION_METHODS.join(", ")}.`; + } + + const openIndex = source.indexOf("(", shape[0].length - 1); + const closeIndex = findMatchingParen(source, openIndex); + if (closeIndex < 0) return null; + const rawArgs = splitTopLevel(source.slice(openIndex + 1, closeIndex)); + const args = rawArgs.length === 1 && !rawArgs[0]?.trim() ? [] : rawArgs; + + for (const [index, arg] of args.entries()) { + if (!arg.trim() || normalizeJsonArgument(arg) !== null) continue; + const role = shapeSpec.roles[index] ?? `argument ${index + 1}`; + const constructor = findUnsupportedValueConstructor(arg); + if (constructor) { + return `Unsupported value ${constructor}(...) in the ${role} argument of ${method}(). Supported value constructors: ${SUPPORTED_VALUE_CONSTRUCTORS.join(", ")}.`; + } + return `The ${role} argument of ${method}() is not a valid document.`; + } + + const tail = source.slice(closeIndex + 1).trim(); + if (tail) return `Unexpected text after ${method}(...): "${tail.length > 40 ? `${tail.slice(0, 40)}…` : tail}".`; + return `${method}() expects ${shapeSpec.expects}.`; +} + +/** First `Name(` outside a string that is not a constructor the parser understands. */ +function findUnsupportedValueConstructor(argument: string): string | null { + const known = new Set(["ObjectId", "ISODate", "Date", "NumberLong", "NumberInt", "NumberDecimal", "UUID", "BinData", "Timestamp", "MinKey", "MaxKey", "deserialize"]); + let quote: string | null = null; + let escaped = false; + for (let index = 0; index < argument.length; index += 1) { + const char = argument[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; + } + const call = /^(?:new\s+)?([A-Za-z_$][\w$]*)\s*\(/.exec(argument.slice(index)); + if (call && !known.has(call[1]!) && (index === 0 || !/[\w$.]/.test(argument[index - 1]!))) { + return /^new\s/.test(call[0]) ? `new ${call[1]}` : call[1]!; + } + } + return null; +} export interface MongoFindCommand { collection: string; diff --git a/packages/app-tests/mongoShellCommand.test.ts b/packages/app-tests/mongoShellCommand.test.ts index 8be1b6887e..e1bd79793e 100644 --- a/packages/app-tests/mongoShellCommand.test.ts +++ b/packages/app-tests/mongoShellCommand.test.ts @@ -1161,6 +1161,41 @@ test("parseMongoAggregateCommand rejects non-array pipelines and invalid options assert.equal(parseMongoAggregateCommand("db.products.aggregate([], {}, true)"), null); }); +test("describeMongoCommandParseFailure names an unsupported value constructor and where it is", () => { + const message = describeMongoCommandParseFailure('db.reports.updateOne({a: 1}, {$set: {t: Foo("x")}}, {upsert: true})'); + assert.match(message, /Unsupported value Foo\(\.\.\.\) in the update argument of updateOne\(\)/); + assert.match(message, /ObjectId, ISODate, new Date, NumberLong/); + + assert.match(describeMongoCommandParseFailure("db.c.find({a: new Bar()})"), /Unsupported value new Bar\(\.\.\.\) in the filter argument of find\(\)/); + // Text inside a string is not a constructor call. + assert.match(describeMongoCommandParseFailure('db.c.find({a: "Foo(1)", b: })'), /filter argument of find\(\) is not a valid document/); +}); + +test("describeMongoCommandParseFailure explains the argument shape a known method expects", () => { + assert.equal(describeMongoCommandParseFailure("db.c.insertOne({a: 1}, {writeConcern: {w: 1}})"), "insertOne() expects one document."); + assert.equal(describeMongoCommandParseFailure("db.c.deleteOne({a: 1}, {collation: {locale: 'en'}})"), "deleteOne() expects a filter."); + assert.equal(describeMongoCommandParseFailure("db.c.updateOne({a: 1})"), "updateOne() expects a filter, an update, and optional options."); + assert.equal(describeMongoCommandParseFailure("db.runCommand()"), "runCommand() expects one command document."); + assert.equal(describeMongoCommandParseFailure("db.version(1)"), "version() expects no arguments."); + assert.equal(describeMongoCommandParseFailure("db.c.find({a: 1}).count"), 'Unexpected text after find(...): ".count".'); +}); + +test("describeMongoCommandParseFailure names unsupported methods and points at alternatives", () => { + const replaceOne = describeMongoCommandParseFailure("db.c.replaceOne({a: 1}, {b: 2})"); + assert.match(replaceOne, /^Collection method replaceOne\(\) is not supported\. Supported collection methods: find, findOne/); + // Bracket and getCollection targets are recognised too. + assert.match(describeMongoCommandParseFailure('db["my-coll"].renameCollection("x")'), /renameCollection\(\) is not supported/); + assert.match(describeMongoCommandParseFailure('db.getCollection("my-coll").watch()'), /watch\(\) is not supported/); + + assert.match(describeMongoCommandParseFailure("db.stats()"), /db\.stats\(\) is not supported; use db\.runCommand\(\{ dbStats: 1 \}\)/); + assert.match(describeMongoCommandParseFailure('db.getSiblingDB("other").c.find({})'), /use /); + assert.match(describeMongoCommandParseFailure("db.adminCommand({ping: 1})"), /use db\.runCommand/); + assert.match(describeMongoCommandParseFailure("show collections"), /listed in the sidebar/); + + // Leading comments do not hide the command shape. + assert.match(describeMongoCommandParseFailure("// note\ndb.c.bulkWrite([])"), /bulkWrite\(\) is not supported/); +}); + test("describeMongoCommandParseFailure reports unclosed delimiters and shell hints", () => { const unclosed = describeMongoCommandParseFailure("db.uc_user.aggregate([], {explain: true"); assert.match(unclosed, /unclosed/i);