85 lines
4.3 KiB
TypeScript
85 lines
4.3 KiB
TypeScript
/**
|
|
* Thin `db.collection.aggregate(pipeline[, options])` parse + diagnostics.
|
|
* JSON preprocessing lives in ./json.ts — do not re-copy helpers here.
|
|
*/
|
|
|
|
import { findMatchingParen, hasUnclosedMongoDelimiters, normalizeJsonArgument, parseCollectionMethodTarget, parseMongoObjectArgument, splitTopLevel, trimMongoOuterComments } from "./json.js";
|
|
|
|
export interface MongoAggregateCommand {
|
|
collection: string;
|
|
pipeline: string;
|
|
/** Optional second argument to `aggregate(pipeline, options)`, e.g. `{explain: true}`. */
|
|
options?: string;
|
|
}
|
|
|
|
/** Shared unsupported-command hint (Rust SQL backstop copies this wording). */
|
|
export const MONGO_SHELL_COMMAND_HINT =
|
|
"Use MongoDB shell-style commands, for example: db.collection.find({}).limit(100), " +
|
|
"db.collection.aggregate([]), db.collection.aggregate([], { explain: true }), " +
|
|
'db.version(), db.collection.countDocuments({}), db.collection.distinct("field"), ' +
|
|
"db.collection.getIndexes(), db.collection.createIndex({...}), db.createUser({...}), db.runCommand({...}), " +
|
|
"or db.collection.insertOne({...}).";
|
|
|
|
const PIPELINE_MUST_BE_ARRAY = "MongoDB aggregate pipeline must be a JSON array (for example [{ $match: {} }]).";
|
|
const OPTIONS_MUST_BE_OBJECT = "MongoDB aggregate options must be a JSON object (for example { explain: true }).";
|
|
const UNCLOSED_DELIMITERS = "MongoDB command has unclosed parentheses, brackets, braces, or strings.";
|
|
const UNSUPPORTED_CHAINING = "Unsupported MongoDB aggregate form. Use db.collection.aggregate(pipeline) or " + "db.collection.aggregate(pipeline, options). Chaining (for example .limit()) is not supported.";
|
|
|
|
/**
|
|
* Cursor methods that change nothing here: DBX always materialises results, so
|
|
* the `.toArray()` that mongosh and Compass append can simply be dropped.
|
|
*/
|
|
const NOOP_CURSOR_CHAIN = /^(?:\s*\.\s*(?:toArray|pretty)\s*\(\s*\))*\s*$/;
|
|
const EXPECTS_PIPELINE_OR_OPTIONS = "MongoDB aggregate expects aggregate(pipeline) or aggregate(pipeline, options).";
|
|
|
|
type AggregateParseResult = { ok: true; command: MongoAggregateCommand } | { ok: false; reason: string };
|
|
|
|
export function parseMongoAggregateCommand(input: string): MongoAggregateCommand | null {
|
|
const source = input.trim().replace(/;$/, "").trim();
|
|
const parsed = tryParseMongoAggregateCommand(source);
|
|
return parsed?.ok ? parsed.command : null;
|
|
}
|
|
|
|
/**
|
|
* Diagnose shell parse failures so callers do not fall through to a generic SQL rejection.
|
|
* Aggregate-shaped input gets specific reasons; everything else gets {@link MONGO_SHELL_COMMAND_HINT}.
|
|
*/
|
|
export function describeMongoCommandParseFailure(input: string): string {
|
|
const source = trimMongoOuterComments(input).trim().replace(/;$/, "").trim();
|
|
if (!source) return "Empty MongoDB command.";
|
|
if (hasUnclosedMongoDelimiters(source)) return UNCLOSED_DELIMITERS;
|
|
const aggregate = tryParseMongoAggregateCommand(source);
|
|
if (aggregate && !aggregate.ok) return aggregate.reason;
|
|
return MONGO_SHELL_COMMAND_HINT;
|
|
}
|
|
|
|
function tryParseMongoAggregateCommand(source: string): AggregateParseResult | null {
|
|
const target = parseCollectionMethodTarget(source, "aggregate");
|
|
if (!target) return null;
|
|
|
|
const openIndex = source.indexOf("(", target.methodCallIndex);
|
|
const closeIndex = findMatchingParen(source, openIndex);
|
|
if (closeIndex < 0) return { ok: false, reason: UNCLOSED_DELIMITERS };
|
|
if (!NOOP_CURSOR_CHAIN.test(source.slice(closeIndex + 1))) return { ok: false, reason: UNSUPPORTED_CHAINING };
|
|
|
|
const args = splitTopLevel(source.slice(openIndex + 1, closeIndex));
|
|
if (args.length < 1 || args.length > 2) return { ok: false, reason: EXPECTS_PIPELINE_OR_OPTIONS };
|
|
if (args.length === 2 && !args[1]?.trim()) return { ok: false, reason: OPTIONS_MUST_BE_OBJECT };
|
|
|
|
const pipeline = normalizeJsonArgument(args[0] ?? "");
|
|
if (!pipeline) return { ok: false, reason: PIPELINE_MUST_BE_ARRAY };
|
|
try {
|
|
if (!Array.isArray(JSON.parse(pipeline))) return { ok: false, reason: PIPELINE_MUST_BE_ARRAY };
|
|
} catch {
|
|
return { ok: false, reason: PIPELINE_MUST_BE_ARRAY };
|
|
}
|
|
|
|
if (args.length === 2) {
|
|
const options = parseMongoObjectArgument(args[1]);
|
|
if (!options) return { ok: false, reason: OPTIONS_MUST_BE_OBJECT };
|
|
return { ok: true, command: { collection: target.collection, pipeline, options } };
|
|
}
|
|
|
|
return { ok: true, command: { collection: target.collection, pipeline } };
|
|
}
|