fix(mongodb): support aggregate options and parse errors
This commit is contained in:
1 parent
782615586c
commit
bb613b1712
25 files changed
+952
-486
No files matched your search
@@ -2140,8 +2140,8 @@ export async function mongoServerVersion(connectionId: string, database: string,
|
||||
return post("/api/mongo/server-version", { connectionId, database, executionId });
|
||||
}
|
||||
|
||||
export async function mongoAggregateDocuments(connectionId: string, database: string, collection: string, pipelineJson: string, maxRows?: number, executionId?: string): Promise<MongoDocumentResult> {
|
||||
return post("/api/mongo/aggregate-documents", { connectionId, database, collection, pipelineJson, maxRows, executionId });
|
||||
export async function mongoAggregateDocuments(connectionId: string, database: string, collection: string, pipelineJson: string, maxRows?: number, optionsJson?: string, executionId?: string): Promise<MongoDocumentResult> {
|
||||
return post("/api/mongo/aggregate-documents", { connectionId, database, collection, pipelineJson, maxRows, optionsJson, executionId });
|
||||
}
|
||||
|
||||
export async function mongoDistinct(connectionId: string, database: string, collection: string, field: string, filter?: string, executionId?: string): Promise<MongoDocumentResult> {
|
||||
|
||||
@@ -1911,8 +1911,8 @@ export async function mongoServerVersion(connectionId: string, database: string,
|
||||
return invoke("mongo_server_version", { connectionId, database, executionId });
|
||||
}
|
||||
|
||||
export async function mongoAggregateDocuments(connectionId: string, database: string, collection: string, pipelineJson: string, maxRows?: number, executionId?: string): Promise<MongoDocumentResult> {
|
||||
return invoke("mongo_aggregate_documents", { connectionId, database, collection, pipelineJson, maxRows, executionId });
|
||||
export async function mongoAggregateDocuments(connectionId: string, database: string, collection: string, pipelineJson: string, maxRows?: number, optionsJson?: string, executionId?: string): Promise<MongoDocumentResult> {
|
||||
return invoke("mongo_aggregate_documents", { connectionId, database, collection, pipelineJson, maxRows, optionsJson, executionId });
|
||||
}
|
||||
|
||||
export async function mongoDistinct(connectionId: string, database: string, collection: string, field: string, filter?: string, executionId?: string): Promise<MongoDocumentResult> {
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
import type { QueryResult } from "@/types/database";
|
||||
import { mongoDocumentIdForGrid } from "@/lib/mongo/mongoDocumentValues";
|
||||
import {
|
||||
chainedMethodCallPattern,
|
||||
describeMongoCommandParseFailure,
|
||||
findChainedMethodCallIndex,
|
||||
findMatchingParen,
|
||||
MONGO_SHELL_COMMAND_HINT,
|
||||
normalizeJsonArgument,
|
||||
parseCollectionMethodTarget,
|
||||
parseMongoAggregateCommand,
|
||||
quoteUnquotedObjectKeys,
|
||||
splitTopLevel,
|
||||
type MongoAggregateCommand,
|
||||
} from "@dbx-app/mongo-shell";
|
||||
|
||||
export type { MongoAggregateCommand };
|
||||
export { describeMongoCommandParseFailure, MONGO_SHELL_COMMAND_HINT, parseMongoAggregateCommand, quoteUnquotedObjectKeys };
|
||||
|
||||
export interface MongoFindCommand {
|
||||
collection: string;
|
||||
@@ -23,11 +39,6 @@ export interface MongoCountDocumentsCommand {
|
||||
mode: "accurate" | "legacy";
|
||||
}
|
||||
|
||||
export interface MongoAggregateCommand {
|
||||
collection: string;
|
||||
pipeline: string;
|
||||
}
|
||||
|
||||
export interface MongoGetIndexesCommand {
|
||||
collection: string;
|
||||
}
|
||||
@@ -308,31 +319,6 @@ function parseFindCountCommand(source: string): MongoCountDocumentsCommand | nul
|
||||
};
|
||||
}
|
||||
|
||||
export function parseMongoAggregateCommand(input: string): MongoAggregateCommand | null {
|
||||
const source = input.trim().replace(/;$/, "").trim();
|
||||
const target = parseCollectionMethodTarget(source, "aggregate");
|
||||
if (!target) return null;
|
||||
|
||||
const openIndex = source.indexOf("(", target.methodCallIndex);
|
||||
const closeIndex = findMatchingParen(source, openIndex);
|
||||
if (closeIndex < 0 || source.slice(closeIndex + 1).trim()) return null;
|
||||
|
||||
const args = splitTopLevel(source.slice(openIndex + 1, closeIndex));
|
||||
if (args.length !== 1) return null;
|
||||
const pipeline = normalizeJsonArgument(args[0]);
|
||||
if (!pipeline) return null;
|
||||
try {
|
||||
if (!Array.isArray(JSON.parse(pipeline))) return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
collection: target.collection,
|
||||
pipeline,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseMongoDistinctCommand(input: string): MongoDistinctCommand | null {
|
||||
const source = input.trim().replace(/;$/, "").trim();
|
||||
const target = parseCollectionMethodTarget(source, "distinct");
|
||||
@@ -786,82 +772,6 @@ function parseFindTarget(source: string): { collection: string; findCallIndex: n
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseCollectionMethodTarget(source: string, method: string): { collection: string; methodCallIndex: number } | null {
|
||||
const escapedMethod = escapeRegExp(method);
|
||||
const direct = new RegExp(`^db\\s*\\.\\s*([A-Za-z_$][\\w$]*)\\s*\\.\\s*${escapedMethod}\\s*\\(`).exec(source);
|
||||
if (direct) {
|
||||
return {
|
||||
collection: direct[1],
|
||||
methodCallIndex: findChainedMethodCallIndex(source, method),
|
||||
};
|
||||
}
|
||||
|
||||
const getCollection = new RegExp(`^db\\s*\\.\\s*getCollection\\s*\\(\\s*(["'])(.*?)\\1\\s*\\)\\s*\\.\\s*${escapedMethod}\\s*\\(`).exec(source);
|
||||
if (getCollection) {
|
||||
return {
|
||||
collection: getCollection[2],
|
||||
methodCallIndex: findChainedMethodCallIndex(source, method),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeJsonArgument(value: string): string | null {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return "{}";
|
||||
// Rewrite mongo shell constructors that are not valid JSON into the extended
|
||||
// JSON the backend understands (mongo_driver::json_value_to_bson): ObjectId(x)
|
||||
// -> {"$oid":x}, NumberLong(x) -> {"$numberLong":x}, and
|
||||
// ISODate(x)/new Date(x) -> {"$date":x}. Without this a
|
||||
// filter such as { createdAt: { $gte: ISODate("...") } } fails JSON.parse,
|
||||
// the command is left unrecognized and falls through to the SQL executor,
|
||||
// which rejects it with "Use MongoDB-specific commands".
|
||||
const withExtendedJson = replaceMongoShellConstructors(trimmed);
|
||||
const preprocessed = quoteUnquotedObjectKeys(convertSingleQuotedStrings(withExtendedJson));
|
||||
try {
|
||||
JSON.parse(preprocessed);
|
||||
return preprocessed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function replaceMongoShellConstructors(source: string): string {
|
||||
const constructor = /^(ObjectId|NumberLong|ISODate)\s*\(\s*["']([^"']+)["']\s*\)|^(ObjectId|NumberLong)\s*\(\s*(-?\d+)\s*\)|^(?:new\s+Date)\s*\(\s*["']([^"']+)["']\s*\)/;
|
||||
let result = "";
|
||||
let index = 0;
|
||||
while (index < source.length) {
|
||||
const quote = source[index];
|
||||
if (quote === '"' || quote === "'") {
|
||||
const start = index++;
|
||||
while (index < source.length) {
|
||||
if (source[index] === "\\") index += 2;
|
||||
else if (source[index] === quote) {
|
||||
index++;
|
||||
break;
|
||||
} else index++;
|
||||
}
|
||||
result += source.slice(start, index);
|
||||
continue;
|
||||
}
|
||||
const match = source.slice(index).match(constructor);
|
||||
if (!match) {
|
||||
result += source[index++];
|
||||
continue;
|
||||
}
|
||||
if (match[1]) {
|
||||
result += match[1] === "ObjectId" ? `{"$oid":"${match[2]}"}` : match[1] === "NumberLong" ? `{"$numberLong":"${match[2]}"}` : `{"$date":"${match[2]}"}`;
|
||||
} else if (match[3]) {
|
||||
result += match[3] === "NumberLong" ? `{"$numberLong":"${match[4]}"}` : `{"$oid":"${match[4]}"}`;
|
||||
} else {
|
||||
result += `{"$date":"${match[5]}"}`;
|
||||
}
|
||||
index += match[0].length;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function parseMethodArgs(source: string, methodCallIndex: number): string[] | null {
|
||||
const openIndex = source.indexOf("(", methodCallIndex);
|
||||
const closeIndex = findMatchingParen(source, openIndex);
|
||||
@@ -1169,52 +1079,6 @@ function trimMongoOuterCommentRange(source: string, from: number, to: number): M
|
||||
};
|
||||
}
|
||||
|
||||
function convertSingleQuotedStrings(source: string): string {
|
||||
let result = "";
|
||||
let copiedUntil = 0;
|
||||
let quote: string | null = null;
|
||||
let start = 0;
|
||||
let value = "";
|
||||
let escaped = false;
|
||||
|
||||
for (let i = 0; i < source.length; i += 1) {
|
||||
const char = source[i];
|
||||
if (!quote) {
|
||||
if (char === "'") {
|
||||
quote = char;
|
||||
start = i;
|
||||
value = "";
|
||||
escaped = false;
|
||||
} else if (char === '"') {
|
||||
quote = char;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (quote === '"') {
|
||||
if (escaped) escaped = false;
|
||||
else if (char === "\\") escaped = true;
|
||||
else if (char === '"') quote = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (escaped) {
|
||||
value += char;
|
||||
escaped = false;
|
||||
} else if (char === "\\") {
|
||||
escaped = true;
|
||||
} else if (char === "'") {
|
||||
result += source.slice(copiedUntil, start) + JSON.stringify(value);
|
||||
copiedUntil = i + 1;
|
||||
quote = null;
|
||||
} else {
|
||||
value += char;
|
||||
}
|
||||
}
|
||||
|
||||
return quote === "'" ? source : result + source.slice(copiedUntil);
|
||||
}
|
||||
|
||||
function parseMongoDropIndexArgument(args: string[]): string | null {
|
||||
if (args.length !== 1 || !args[0]?.trim()) return null;
|
||||
const normalized = normalizeJsonArgument(args[0]);
|
||||
@@ -1235,52 +1099,6 @@ function parseMongoDropIndexesArgument(args: string[]): string | undefined | nul
|
||||
return Array.isArray(parsed) && parsed.length > 0 && parsed.every((item) => typeof item === "string") ? normalized : null;
|
||||
}
|
||||
|
||||
export function quoteUnquotedObjectKeys(source: string): string {
|
||||
let result = "";
|
||||
let quote: string | null = null;
|
||||
let escaped = false;
|
||||
|
||||
for (let i = 0; i < source.length; i += 1) {
|
||||
const char = source[i];
|
||||
if (quote) {
|
||||
result += char;
|
||||
if (escaped) escaped = false;
|
||||
else if (char === "\\") escaped = true;
|
||||
else if (char === quote) quote = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '"' || char === "'") {
|
||||
quote = char;
|
||||
result += char;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/[A-Za-z_$]/.test(char) && shouldQuoteObjectKey(source, i)) {
|
||||
let end = i + 1;
|
||||
while (/[\w$]/.test(source[end] || "")) end += 1;
|
||||
result += `"${source.slice(i, end)}"`;
|
||||
i = end - 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
result += char;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function shouldQuoteObjectKey(source: string, index: number): boolean {
|
||||
let before = index - 1;
|
||||
while (/\s/.test(source[before] || "")) before -= 1;
|
||||
if (source[before] !== "{" && source[before] !== ",") return false;
|
||||
|
||||
let after = index + 1;
|
||||
while (/[\w$]/.test(source[after] || "")) after += 1;
|
||||
while (/\s/.test(source[after] || "")) after += 1;
|
||||
return source[after] === ":";
|
||||
}
|
||||
|
||||
function readChainedIntegerArgument(source: string, name: string, fallback: number): number | null {
|
||||
const raw = readChainedCallArgument(source, name);
|
||||
if (raw === undefined) return fallback;
|
||||
@@ -1325,73 +1143,6 @@ function hasSingleEmptyChainedCall(source: string, name: string): boolean {
|
||||
return closeIndex >= 0 && !trimmed.slice(openIndex + 1, closeIndex).trim() && !trimmed.slice(closeIndex + 1).trim();
|
||||
}
|
||||
|
||||
function findChainedMethodCallIndex(source: string, name: string): number {
|
||||
return chainedMethodCallPattern(name).exec(source)?.index ?? -1;
|
||||
}
|
||||
|
||||
function chainedMethodCallPattern(name: string): RegExp {
|
||||
return new RegExp(`\\.\\s*${escapeRegExp(name)}\\s*\\(`, "g");
|
||||
}
|
||||
|
||||
function splitTopLevel(source: string): string[] {
|
||||
const parts: string[] = [];
|
||||
let start = 0;
|
||||
let depth = 0;
|
||||
let quote: string | null = null;
|
||||
let escaped = false;
|
||||
|
||||
for (let i = 0; i < source.length; i += 1) {
|
||||
const char = source[i];
|
||||
if (quote) {
|
||||
if (escaped) escaped = false;
|
||||
else if (char === "\\") escaped = true;
|
||||
else if (char === quote) quote = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '"' || char === "'") quote = char;
|
||||
else if (char === "{" || char === "[" || char === "(") depth += 1;
|
||||
else if (char === "}" || char === "]" || char === ")") depth -= 1;
|
||||
else if (char === "," && depth === 0) {
|
||||
parts.push(source.slice(start, i).trim());
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
|
||||
parts.push(source.slice(start).trim());
|
||||
return parts;
|
||||
}
|
||||
|
||||
function findMatchingParen(source: string, openIndex: number): number {
|
||||
if (source[openIndex] !== "(") return -1;
|
||||
let depth = 0;
|
||||
let quote: string | null = null;
|
||||
let escaped = false;
|
||||
|
||||
for (let i = openIndex; i < source.length; i += 1) {
|
||||
const char = source[i];
|
||||
if (quote) {
|
||||
if (escaped) escaped = false;
|
||||
else if (char === "\\") escaped = true;
|
||||
else if (char === quote) quote = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '"' || char === "'") quote = char;
|
||||
else if (char === "(") depth += 1;
|
||||
else if (char === ")") {
|
||||
depth -= 1;
|
||||
if (depth === 0) return i;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function parseNormalizedJson(json: string): unknown {
|
||||
try {
|
||||
return JSON.parse(json);
|
||||
|
||||
Reference in new issue
Block a user