feat(mongo): improve completion and add distinct support

This commit is contained in:
Võ Văn Thành (Jay)
2026-07-16 18:21:10 +08:00
committed by GitHub
parent 5bcad3adc6
commit 640e82cdc6
19 changed files with 1865 additions and 275 deletions
@@ -38,7 +38,7 @@ import { sqlCompletionContextFromSemantic } from "@/lib/sql/semantic/completion"
import { buildSqlSemanticModel } from "@/lib/sql/semantic/model";
import { mergeSqlSemanticReferenceAnalysis, resolveSqlSemanticNavigationTarget } from "@/lib/sql/semantic/references";
import { buildElasticsearchCompletionItemsFromContext, getElasticsearchCompletionContext, getElasticsearchCompletionResultValidFor, shouldAutoOpenElasticsearchCompletion, type ElasticsearchCompletionItem } from "@/lib/elasticsearch/elasticsearchCompletion";
import { buildMongoCompletionItemsFromContext, getMongoCompletionContext, getMongoCompletionResultValidFor, shouldAutoOpenMongoCompletion, type MongoCompletionItem } from "@/lib/mongo/mongoCompletion";
import { buildMongoCompletionItemsFromContext, getMongoCompletionContext, getMongoCompletionResultValidFor, mongoCompletionNeedsCollections, mongoCompletionNeedsFields, shouldAutoOpenMongoCompletion, type MongoCompletionItem } from "@/lib/mongo/mongoCompletion";
import { resolveSqlCompletionTableLookupTarget } from "@/lib/sql/sqlCompletionLookupTarget";
import { extractIdentifierDetailsAt, isSqlKeyword, matchTable, mergeSqlObjectNavigationType, splitQualifiedIdentifier, sqlObjectHoverDetail, sqlObjectNavigationTarget, type SqlObjectNavigationTarget } from "@/lib/sql/sqlNavigation";
import { lineColumnToOffset, parseSqlErrorLocation } from "@/lib/sql/sqlDiagnostics";
@@ -1983,7 +1983,7 @@ async function provideMongoCompletions(currentState: import("@codemirror/state")
let collections: string[] = [];
let fields: Awaited<ReturnType<typeof connectionStore.listMongoCompletionFields>> = [];
if (props.database && completionContext.mode === "collection") {
if (props.database && mongoCompletionNeedsCollections(completionContext.mode)) {
try {
collections = await connectionStore.listMongoCompletionCollections(props.connectionId, props.database);
} catch {
@@ -1991,7 +1991,7 @@ async function provideMongoCompletions(currentState: import("@codemirror/state")
}
}
if (props.database && completionContext.mode === "field" && completionContext.collection) {
if (props.database && mongoCompletionNeedsFields(completionContext.mode) && completionContext.collection) {
try {
fields = await connectionStore.listMongoCompletionFields(props.connectionId, props.database, completionContext.collection);
} catch {
+1
View File
@@ -447,6 +447,7 @@ export const mongoFindOne = forward("mongoFindOne");
export const mongoCountDocuments = forward("mongoCountDocuments");
export const mongoServerVersion = forward("mongoServerVersion");
export const mongoAggregateDocuments = forward("mongoAggregateDocuments");
export const mongoDistinct = forward("mongoDistinct");
export const mongoCollectionStats = forward("mongoCollectionStats");
export const mongoCreateIndex = forward("mongoCreateIndex");
export const mongoDropIndexes = forward("mongoDropIndexes");
+4
View File
@@ -2131,6 +2131,10 @@ export async function mongoAggregateDocuments(connectionId: string, database: st
return post("/api/mongo/aggregate-documents", { connectionId, database, collection, pipelineJson, maxRows, executionId });
}
export async function mongoDistinct(connectionId: string, database: string, collection: string, field: string, filter?: string, executionId?: string): Promise<MongoDocumentResult> {
return post("/api/mongo/distinct", { connectionId, database, collection, field, filter, executionId });
}
export async function mongoCollectionStats(connectionId: string, database: string, collection: string, scale?: number, executionId?: string): Promise<MongoCollectionStatsResult> {
return post("/api/mongo/collection-stats", { connectionId, database, collection, scale, executionId });
}
+4
View File
@@ -1907,6 +1907,10 @@ export async function mongoAggregateDocuments(connectionId: string, database: st
return invoke("mongo_aggregate_documents", { connectionId, database, collection, pipelineJson, maxRows, executionId });
}
export async function mongoDistinct(connectionId: string, database: string, collection: string, field: string, filter?: string, executionId?: string): Promise<MongoDocumentResult> {
return invoke("mongo_distinct", { connectionId, database, collection, field, filter, executionId });
}
export async function mongoCollectionStats(connectionId: string, database: string, collection: string, scale?: number, executionId?: string): Promise<MongoCollectionStatsResult> {
return invoke("mongo_collection_stats", { connectionId, database, collection, scale, executionId });
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,429 @@
/**
* Static MongoDB operator metadata for editor completion, distilled from the
* MongoDB manual (Query and Projection Operators, Update Operators, Aggregation
* Pipeline Stages / Operators). Kept offline and lightweight: each entry carries
* only what the completion list renders — a label, a one-line `detail`, and the
* text to insert.
*
* `apply` is the text inserted when the item is accepted. Entries containing a
* `${…}` placeholder are inserted as CodeMirror snippets (see
* `mongoOperatorItemType`); the rest are inserted verbatim. Every `apply` here
* writes the operator *and* its `:` separator, because these items are only ever
* offered in key position inside an object literal.
*
* The tables are deliberately grouped by where they may legally appear, so the
* completion engine never has to filter one flat list by context:
* QUERY_OPERATORS → filter documents (`find`, `$match`, …)
* UPDATE_OPERATORS → update documents (`updateOne`, `findOneAndUpdate`, …)
* PUSH_MODIFIERS → the object value of `$push` / `$addToSet`
* PIPELINE_STAGES → elements of an aggregation pipeline array
* ACCUMULATORS → the output fields of `$group`
* EXPRESSION_OPERATORS → aggregation expression position (`$project`, `$expr`, …)
*/
export interface MongoOperatorSpec {
label: string;
detail: string;
apply: string;
}
type Spec = [label: string, detail: string, apply: string];
function specs(entries: readonly Spec[]): MongoOperatorSpec[] {
return entries.map(([label, detail, apply]) => ({ label, detail, apply }));
}
/** Snippet placeholders only work for `snippet`/`function` completions; plain text is inserted as-is. */
export function mongoOperatorItemType(apply: string): "snippet" | "keyword" {
return apply.includes("${") ? "snippet" : "keyword";
}
export const QUERY_OPERATORS: MongoOperatorSpec[] = specs([
["$eq", "Matches values equal to a value", "$eq: ${}"],
["$ne", "Matches values not equal to a value", "$ne: ${}"],
["$gt", "Matches values greater than a value", "$gt: ${}"],
["$gte", "Matches values greater than or equal to a value", "$gte: ${}"],
["$lt", "Matches values less than a value", "$lt: ${}"],
["$lte", "Matches values less than or equal to a value", "$lte: ${}"],
["$in", "Matches any value in an array", "$in: [${}]"],
["$nin", "Matches no value in an array", "$nin: [${}]"],
["$and", "Joins clauses with a logical AND", "$and: [${}]"],
["$or", "Joins clauses with a logical OR", "$or: [${}]"],
["$nor", "Joins clauses with a logical NOR", "$nor: [${}]"],
["$not", "Inverts the effect of a query expression", "$not: { ${} }"],
["$exists", "Matches documents that have the field", "$exists: true"],
["$type", "Matches documents by BSON type", '$type: "${string}"'],
["$regex", "Matches a regular expression", '$regex: "${pattern}"'],
["$text", "Performs a text search", '$text: { $search: "${text}" }'],
["$expr", "Uses aggregation expressions in a query", "$expr: { ${} }"],
["$jsonSchema", "Matches documents against a JSON schema", "$jsonSchema: { ${} }"],
["$mod", "Matches values by modulo division", "$mod: [${divisor}, ${remainder}]"],
["$where", "Matches with a JavaScript predicate", '$where: "${expression}"'],
["$all", "Matches arrays containing all the values", "$all: [${}]"],
["$elemMatch", "Matches arrays with an element matching all criteria", "$elemMatch: { ${} }"],
["$size", "Matches arrays of a given length", "$size: ${}"],
["$bitsAllSet", "Matches numbers with all the given bits set", "$bitsAllSet: [${}]"],
["$bitsAnySet", "Matches numbers with any of the given bits set", "$bitsAnySet: [${}]"],
["$bitsAllClear", "Matches numbers with all the given bits clear", "$bitsAllClear: [${}]"],
["$bitsAnyClear", "Matches numbers with any of the given bits clear", "$bitsAnyClear: [${}]"],
["$geoWithin", "Matches geometry contained within a shape", "$geoWithin: { ${} }"],
["$geoIntersects", "Matches geometry intersecting a shape", "$geoIntersects: { ${} }"],
["$near", "Matches points near a point, nearest first", "$near: { ${} }"],
["$nearSphere", "Matches points near a point on a sphere", "$nearSphere: { ${} }"],
]);
export const UPDATE_OPERATORS: MongoOperatorSpec[] = specs([
["$set", "Sets field values", "$set: { ${} }"],
["$unset", "Removes fields", '$unset: { ${field}: "" }'],
["$setOnInsert", "Sets field values only when an upsert inserts", "$setOnInsert: { ${} }"],
["$inc", "Increments field values", "$inc: { ${field}: 1 }"],
["$mul", "Multiplies field values", "$mul: { ${field}: 2 }"],
["$min", "Updates only when the new value is lower", "$min: { ${field}: ${} }"],
["$max", "Updates only when the new value is higher", "$max: { ${field}: ${} }"],
["$rename", "Renames fields", '$rename: { ${field}: "${newName}" }'],
["$currentDate", "Sets fields to the current date", "$currentDate: { ${field}: true }"],
["$push", "Appends values to an array", "$push: { ${field}: ${} }"],
["$addToSet", "Appends values to an array without duplicates", "$addToSet: { ${field}: ${} }"],
["$pop", "Removes the first or last element of an array", "$pop: { ${field}: 1 }"],
["$pull", "Removes array elements matching a condition", "$pull: { ${field}: ${} }"],
["$pullAll", "Removes all the listed values from an array", "$pullAll: { ${field}: [${}] }"],
["$bit", "Performs a bitwise update", "$bit: { ${field}: { and: ${} } }"],
]);
/** Valid inside the object value of `$push` / `$addToSet`, and nowhere else. */
export const PUSH_MODIFIERS: MongoOperatorSpec[] = specs([
["$each", "Appends multiple values", "$each: [${}]"],
["$slice", "Limits the array length after the push", "$slice: ${}"],
["$sort", "Sorts the array elements after the push", "$sort: { ${field}: 1 }"],
["$position", "Insert position for $each", "$position: 0"],
]);
export const PIPELINE_STAGES: MongoOperatorSpec[] = specs([
["$match", "Filters documents", "$match: { ${} }"],
["$project", "Reshapes documents", "$project: { ${} }"],
["$group", "Groups documents by a key", '$group: { _id: "$${field}", count: { $sum: 1 } }'],
["$sort", "Sorts documents", "$sort: { ${field}: -1 }"],
["$limit", "Limits the number of documents", "$limit: 10"],
["$skip", "Skips documents", "$skip: 0"],
["$count", "Counts documents into a single field", '$count: "${total}"'],
["$unwind", "Deconstructs an array field into one document per element", '$unwind: "$${field}"'],
["$lookup", "Joins another collection", '$lookup: { from: "${collection}", localField: "${field}", foreignField: "_id", as: "${as}" }'],
["$addFields", "Adds new fields to documents", "$addFields: { ${} }"],
["$set", "Adds new fields to documents (alias of $addFields)", "$set: { ${} }"],
["$unset", "Removes fields from documents", '$unset: "${field}"'],
["$replaceRoot", "Promotes a subdocument to the top level", '$replaceRoot: { newRoot: "$${field}" }'],
["$replaceWith", "Promotes a subdocument to the top level", '$replaceWith: "$${field}"'],
["$facet", "Runs multiple pipelines over the same input", "$facet: { ${name}: [${}] }"],
["$sortByCount", "Groups by a value and counts, highest first", '$sortByCount: "$${field}"'],
["$bucket", "Groups documents into user-defined buckets", '$bucket: { groupBy: "$${field}", boundaries: [${}], default: "other" }'],
["$bucketAuto", "Groups documents into evenly distributed buckets", '$bucketAuto: { groupBy: "$${field}", buckets: 5 }'],
["$sample", "Randomly selects documents", "$sample: { size: 10 }"],
["$unionWith", "Combines results with another collection", '$unionWith: { coll: "${collection}", pipeline: [${}] }'],
["$graphLookup", "Performs a recursive search on a collection", '$graphLookup: { from: "${collection}", startWith: "$${field}", connectFromField: "${field}", connectToField: "_id", as: "${as}" }'],
["$geoNear", "Orders documents by proximity to a point", '$geoNear: { near: { type: "Point", coordinates: [0, 0] }, distanceField: "${distance}" }'],
["$setWindowFields", "Adds fields computed over a window of documents", '$setWindowFields: { partitionBy: "$${field}", sortBy: { ${field}: 1 }, output: {} }'],
["$densify", "Fills gaps in a sequence of field values", '$densify: { field: "${field}", range: { step: 1, unit: "${unit}" } }'],
["$fill", "Fills null or missing field values", "$fill: { output: { ${field}: { value: ${} } } }"],
["$documents", "Returns literal documents", "$documents: [${}]"],
["$redact", "Restricts document content based on a condition", "$redact: { ${} }"],
["$out", "Writes the results to a collection", '$out: "${collection}"'],
["$merge", "Merges the results into a collection", '$merge: { into: "${collection}" }'],
["$collStats", "Returns statistics about the collection", "$collStats: { ${} }"],
["$indexStats", "Returns index usage statistics", "$indexStats: {}"],
["$planCacheStats", "Returns plan cache information", "$planCacheStats: {}"],
]);
export const ACCUMULATORS: MongoOperatorSpec[] = specs([
["$sum", "Sums values", "$sum: 1"],
["$avg", "Averages values", '$avg: "$${field}"'],
["$min", "Returns the lowest value", '$min: "$${field}"'],
["$max", "Returns the highest value", '$max: "$${field}"'],
["$count", "Counts the documents in the group", "$count: {}"],
["$first", "Returns the first value in the group", '$first: "$${field}"'],
["$last", "Returns the last value in the group", '$last: "$${field}"'],
["$push", "Collects values into an array", '$push: "$${field}"'],
["$addToSet", "Collects unique values into an array", '$addToSet: "$${field}"'],
["$mergeObjects", "Merges documents into one", '$mergeObjects: "$${field}"'],
["$stdDevPop", "Population standard deviation", '$stdDevPop: "$${field}"'],
["$stdDevSamp", "Sample standard deviation", '$stdDevSamp: "$${field}"'],
["$top", "Returns the top element by a sort order", '$top: { sortBy: { ${field}: -1 }, output: "$${field}" }'],
["$topN", "Returns the top N elements by a sort order", '$topN: { n: 3, sortBy: { ${field}: -1 }, output: "$${field}" }'],
["$bottom", "Returns the bottom element by a sort order", '$bottom: { sortBy: { ${field}: -1 }, output: "$${field}" }'],
["$bottomN", "Returns the bottom N elements by a sort order", '$bottomN: { n: 3, sortBy: { ${field}: -1 }, output: "$${field}" }'],
["$firstN", "Returns the first N values in the group", '$firstN: { input: "$${field}", n: 3 }'],
["$lastN", "Returns the last N values in the group", '$lastN: { input: "$${field}", n: 3 }'],
["$maxN", "Returns the N highest values", '$maxN: { input: "$${field}", n: 3 }'],
["$minN", "Returns the N lowest values", '$minN: { input: "$${field}", n: 3 }'],
["$median", "Approximates the median", '$median: { input: "$${field}", method: "approximate" }'],
["$percentile", "Approximates the requested percentiles", '$percentile: { input: "$${field}", p: [0.95], method: "approximate" }'],
]);
export const EXPRESSION_OPERATORS: MongoOperatorSpec[] = specs([
// Conditional
["$cond", "Returns one of two values from a condition", "$cond: { if: ${}, then: ${}, else: ${} }"],
["$ifNull", "Returns the first non-null value", "$ifNull: [${}, ${}]"],
["$switch", "Evaluates branches in order", "$switch: { branches: [{ case: ${}, then: ${} }], default: ${} }"],
// Comparison / boolean
["$eq", "Returns true when the values are equal", "$eq: [${}, ${}]"],
["$ne", "Returns true when the values differ", "$ne: [${}, ${}]"],
["$gt", "Returns true when the first value is greater", "$gt: [${}, ${}]"],
["$gte", "Returns true when the first value is greater or equal", "$gte: [${}, ${}]"],
["$lt", "Returns true when the first value is less", "$lt: [${}, ${}]"],
["$lte", "Returns true when the first value is less or equal", "$lte: [${}, ${}]"],
["$cmp", "Compares two values and returns -1, 0 or 1", "$cmp: [${}, ${}]"],
["$and", "Logical AND", "$and: [${}]"],
["$or", "Logical OR", "$or: [${}]"],
["$not", "Logical NOT", "$not: [${}]"],
// Arithmetic
["$add", "Adds numbers or a number to a date", "$add: [${}, ${}]"],
["$subtract", "Subtracts two numbers or dates", "$subtract: [${}, ${}]"],
["$multiply", "Multiplies numbers", "$multiply: [${}, ${}]"],
["$divide", "Divides two numbers", "$divide: [${}, ${}]"],
["$mod", "Returns the remainder of a division", "$mod: [${}, ${}]"],
["$abs", "Absolute value", "$abs: ${}"],
["$ceil", "Rounds up to the next integer", "$ceil: ${}"],
["$floor", "Rounds down to the previous integer", "$floor: ${}"],
["$round", "Rounds to a number of decimal places", "$round: [${}, 0]"],
["$trunc", "Truncates to a number of decimal places", "$trunc: [${}, 0]"],
["$pow", "Raises a number to an exponent", "$pow: [${}, 2]"],
["$sqrt", "Square root", "$sqrt: ${}"],
["$ln", "Natural logarithm", "$ln: ${}"],
["$log", "Logarithm in the given base", "$log: [${}, 10]"],
["$log10", "Base 10 logarithm", "$log10: ${}"],
["$exp", "Raises e to an exponent", "$exp: ${}"],
// Strings
["$concat", "Concatenates strings", "$concat: [${}, ${}]"],
["$split", "Splits a string on a delimiter", '$split: [${}, "${,}"]'],
["$substr", "Returns a substring", "$substr: [${}, 0, 1]"],
["$substrBytes", "Returns a substring by byte index", "$substrBytes: [${}, 0, 1]"],
["$substrCP", "Returns a substring by code point index", "$substrCP: [${}, 0, 1]"],
["$strLenCP", "Number of code points in a string", "$strLenCP: ${}"],
["$strLenBytes", "Number of bytes in a string", "$strLenBytes: ${}"],
["$strcasecmp", "Case-insensitive string comparison", "$strcasecmp: [${}, ${}]"],
["$toLower", "Converts a string to lower case", "$toLower: ${}"],
["$toUpper", "Converts a string to upper case", "$toUpper: ${}"],
["$trim", "Removes whitespace from both ends", "$trim: { input: ${} }"],
["$ltrim", "Removes whitespace from the start", "$ltrim: { input: ${} }"],
["$rtrim", "Removes whitespace from the end", "$rtrim: { input: ${} }"],
["$replaceOne", "Replaces the first match in a string", "$replaceOne: { input: ${}, find: ${}, replacement: ${} }"],
["$replaceAll", "Replaces every match in a string", "$replaceAll: { input: ${}, find: ${}, replacement: ${} }"],
["$regexMatch", "Returns true when a regex matches", '$regexMatch: { input: ${}, regex: "${pattern}" }'],
["$regexFind", "Returns the first regex match", '$regexFind: { input: ${}, regex: "${pattern}" }'],
["$regexFindAll", "Returns every regex match", '$regexFindAll: { input: ${}, regex: "${pattern}" }'],
["$indexOfCP", "Position of a substring by code point", "$indexOfCP: [${}, ${}]"],
["$indexOfBytes", "Position of a substring by byte", "$indexOfBytes: [${}, ${}]"],
// Arrays
["$size", "Number of elements in an array", "$size: ${}"],
["$arrayElemAt", "Element at an array index", "$arrayElemAt: [${}, 0]"],
["$first", "First element of an array", "$first: ${}"],
["$last", "Last element of an array", "$last: ${}"],
["$slice", "Returns a subset of an array", "$slice: [${}, 3]"],
["$in", "Returns true when a value is in an array", "$in: [${}, ${}]"],
["$indexOfArray", "Position of a value in an array", "$indexOfArray: [${}, ${}]"],
["$concatArrays", "Concatenates arrays", "$concatArrays: [${}, ${}]"],
["$filter", "Selects array elements matching a condition", '$filter: { input: ${}, as: "${item}", cond: ${} }'],
["$map", "Applies an expression to each array element", '$map: { input: ${}, as: "${item}", in: ${} }'],
["$reduce", "Folds an array into a single value", "$reduce: { input: ${}, initialValue: ${}, in: ${} }"],
["$reverseArray", "Reverses an array", "$reverseArray: ${}"],
["$sortArray", "Sorts an array", "$sortArray: { input: ${}, sortBy: 1 }"],
["$range", "Generates a sequence of numbers", "$range: [0, 10]"],
["$zip", "Merges arrays element by element", "$zip: { inputs: [${}] }"],
["$isArray", "Returns true when the value is an array", "$isArray: ${}"],
["$arrayToObject", "Converts an array of pairs to a document", "$arrayToObject: ${}"],
["$objectToArray", "Converts a document to an array of pairs", "$objectToArray: ${}"],
["$setUnion", "Union of arrays as sets", "$setUnion: [${}, ${}]"],
["$setIntersection", "Intersection of arrays as sets", "$setIntersection: [${}, ${}]"],
["$setDifference", "Difference of arrays as sets", "$setDifference: [${}, ${}]"],
["$setEquals", "Returns true when the sets are equal", "$setEquals: [${}, ${}]"],
["$setIsSubset", "Returns true when the first set is a subset", "$setIsSubset: [${}, ${}]"],
["$allElementsTrue", "Returns true when every element is true", "$allElementsTrue: [${}]"],
["$anyElementTrue", "Returns true when any element is true", "$anyElementTrue: [${}]"],
// Dates
["$dateToString", "Formats a date as a string", '$dateToString: { format: "%Y-%m-%d", date: "$${field}" }'],
["$dateFromString", "Parses a string into a date", "$dateFromString: { dateString: ${} }"],
["$dateToParts", "Splits a date into its parts", '$dateToParts: { date: "$${field}" }'],
["$dateFromParts", "Builds a date from its parts", "$dateFromParts: { year: ${}, month: ${}, day: ${} }"],
["$dateAdd", "Adds a time unit to a date", '$dateAdd: { startDate: "$${field}", unit: "day", amount: 1 }'],
["$dateSubtract", "Subtracts a time unit from a date", '$dateSubtract: { startDate: "$${field}", unit: "day", amount: 1 }'],
["$dateDiff", "Difference between two dates", '$dateDiff: { startDate: ${}, endDate: ${}, unit: "day" }'],
["$dateTrunc", "Truncates a date to a time unit", '$dateTrunc: { date: "$${field}", unit: "day" }'],
["$year", "Year of a date", '$year: "$${field}"'],
["$month", "Month of a date", '$month: "$${field}"'],
["$dayOfMonth", "Day of the month of a date", '$dayOfMonth: "$${field}"'],
["$dayOfWeek", "Day of the week of a date", '$dayOfWeek: "$${field}"'],
["$dayOfYear", "Day of the year of a date", '$dayOfYear: "$${field}"'],
["$week", "Week of the year of a date", '$week: "$${field}"'],
["$hour", "Hour of a date", '$hour: "$${field}"'],
["$minute", "Minute of a date", '$minute: "$${field}"'],
["$second", "Second of a date", '$second: "$${field}"'],
["$millisecond", "Millisecond of a date", '$millisecond: "$${field}"'],
["$isoWeek", "ISO week number of a date", '$isoWeek: "$${field}"'],
["$isoWeekYear", "ISO week-numbering year of a date", '$isoWeekYear: "$${field}"'],
["$isoDayOfWeek", "ISO day of the week of a date", '$isoDayOfWeek: "$${field}"'],
// Types and misc
["$type", "Returns the BSON type of a value", "$type: ${}"],
["$convert", "Converts a value to a given type", '$convert: { input: ${}, to: "${string}" }'],
["$toString", "Converts a value to a string", "$toString: ${}"],
["$toInt", "Converts a value to an integer", "$toInt: ${}"],
["$toLong", "Converts a value to a 64-bit integer", "$toLong: ${}"],
["$toDouble", "Converts a value to a double", "$toDouble: ${}"],
["$toDecimal", "Converts a value to a decimal", "$toDecimal: ${}"],
["$toBool", "Converts a value to a boolean", "$toBool: ${}"],
["$toDate", "Converts a value to a date", "$toDate: ${}"],
["$toObjectId", "Converts a value to an ObjectId", "$toObjectId: ${}"],
["$isNumber", "Returns true when the value is a number", "$isNumber: ${}"],
["$literal", "Returns a value without parsing it", "$literal: ${}"],
["$mergeObjects", "Merges documents into one", "$mergeObjects: [${}, ${}]"],
["$getField", "Reads a field, including names starting with $", '$getField: "${field}"'],
["$setField", "Writes a field, including names starting with $", '$setField: { field: "${field}", input: "$$ROOT", value: ${} }'],
["$let", "Binds variables for use in an expression", "$let: { vars: { ${name}: ${} }, in: ${} }"],
["$rand", "Returns a random float between 0 and 1", "$rand: {}"],
]);
/** Option keys accepted by the stages whose shape is a fixed set of names. */
export const STAGE_OPTION_KEYS: Record<string, MongoOperatorSpec[]> = {
$lookup: specs([
["from", "Collection to join", 'from: "${collection}"'],
["localField", "Field on the input documents", 'localField: "${field}"'],
["foreignField", "Field on the joined collection", 'foreignField: "_id"'],
["as", "Output array field", 'as: "${as}"'],
["let", "Variables for the joined pipeline", "let: { ${name}: ${} }"],
["pipeline", "Pipeline to run on the joined collection", "pipeline: [${}]"],
]),
$graphLookup: specs([
["from", "Collection to search", 'from: "${collection}"'],
["startWith", "Expression to start the search from", 'startWith: "$${field}"'],
["connectFromField", "Field to recurse from", 'connectFromField: "${field}"'],
["connectToField", "Field to match against", 'connectToField: "_id"'],
["as", "Output array field", 'as: "${as}"'],
["maxDepth", "Maximum recursion depth", "maxDepth: 3"],
["depthField", "Field holding the recursion depth", 'depthField: "${depth}"'],
["restrictSearchWithMatch", "Extra filter on the searched documents", "restrictSearchWithMatch: { ${} }"],
]),
$unwind: specs([
["path", "Array field to unwind", 'path: "$${field}"'],
["includeArrayIndex", "Field holding the array index", 'includeArrayIndex: "${index}"'],
["preserveNullAndEmptyArrays", "Keep documents with a missing or empty array", "preserveNullAndEmptyArrays: true"],
]),
$merge: specs([
["into", "Target collection", 'into: "${collection}"'],
["on", "Fields identifying a document", 'on: "_id"'],
["let", "Variables for whenMatched", "let: { ${name}: ${} }"],
["whenMatched", "Action when a document matches", 'whenMatched: "${merge}"'],
["whenNotMatched", "Action when no document matches", 'whenNotMatched: "${insert}"'],
]),
$bucket: specs([
["groupBy", "Expression to group by", 'groupBy: "$${field}"'],
["boundaries", "Bucket boundaries", "boundaries: [${}]"],
["default", "Bucket for values outside the boundaries", 'default: "${other}"'],
["output", "Accumulators for each bucket", "output: { ${} }"],
]),
$bucketAuto: specs([
["groupBy", "Expression to group by", 'groupBy: "$${field}"'],
["buckets", "Number of buckets", "buckets: 5"],
["output", "Accumulators for each bucket", "output: { ${} }"],
["granularity", "Preferred number series", 'granularity: "${R20}"'],
]),
$sample: specs([["size", "Number of documents to return", "size: 10"]]),
$unionWith: specs([
["coll", "Collection to union with", 'coll: "${collection}"'],
["pipeline", "Pipeline to run on that collection", "pipeline: [${}]"],
]),
$setWindowFields: specs([
["partitionBy", "Expression to partition by", 'partitionBy: "$${field}"'],
["sortBy", "Sort order within a partition", "sortBy: { ${field}: 1 }"],
["output", "Window accumulators", "output: { ${} }"],
]),
$geoNear: specs([
["near", "Point to measure from", 'near: { type: "Point", coordinates: [0, 0] }'],
["distanceField", "Field holding the computed distance", 'distanceField: "${distance}"'],
["maxDistance", "Maximum distance in meters", "maxDistance: 1000"],
["minDistance", "Minimum distance in meters", "minDistance: 0"],
["query", "Extra filter on the documents", "query: { ${} }"],
["spherical", "Use spherical geometry", "spherical: true"],
["key", "Geospatial index to use", 'key: "${field}"'],
["includeLocs", "Field holding the matched location", 'includeLocs: "${location}"'],
]),
$replaceRoot: specs([["newRoot", "Expression producing the new root document", 'newRoot: "$${field}"']]),
};
/** Literal values that are worth completing in value position. */
export const VALUE_SNIPPETS: MongoOperatorSpec[] = specs([
["ObjectId", "MongoDB ObjectId value", 'ObjectId("${id}")'],
["ISODate", "MongoDB ISODate value", 'ISODate("${date}")'],
["new Date", "JavaScript date value", 'new Date("${date}")'],
["NumberLong", "64-bit integer value", 'NumberLong("${value}")'],
["null", "Null value", "null"],
["true", "Boolean true", "true"],
["false", "Boolean false", "false"],
]);
/**
* Everyday operators, floated above the long tail. Without this the lists sort
* alphabetically once the user has typed only `$`, which buries `$eq` and
* `$match` under `$bitsAllClear` and `$bucketAuto`. Matched per table, so a
* label common in one position (`$set` as an update operator) does not have to
* be common in another.
*/
export const COMMON_OPERATORS: ReadonlySet<string> = new Set([
// query
"$eq",
"$ne",
"$gt",
"$gte",
"$lt",
"$lte",
"$in",
"$nin",
"$and",
"$or",
"$not",
"$exists",
"$regex",
"$elemMatch",
"$expr",
// update
"$set",
"$unset",
"$inc",
"$push",
"$pull",
"$addToSet",
"$each",
// stages
"$match",
"$group",
"$project",
"$sort",
"$limit",
"$skip",
"$lookup",
"$unwind",
"$count",
"$addFields",
// accumulators
"$sum",
"$avg",
"$first",
"$last",
"$max",
"$min",
// expressions
"$cond",
"$ifNull",
"$concat",
"$toString",
"$dateToString",
"$size",
"$arrayElemAt",
"$add",
"$subtract",
"$multiply",
"$filter",
"$map",
// values
"ObjectId",
"ISODate",
]);
export const UPDATE_OPERATOR_LABELS: ReadonlySet<string> = new Set(UPDATE_OPERATORS.map((operator) => operator.label));
+116 -24
View File
@@ -48,6 +48,12 @@ export interface MongoCollectionStatsCommand {
scale?: number;
}
export interface MongoDistinctCommand {
collection: string;
field: string;
filter?: string;
}
type MongoWriteKind = "insert" | "update" | "delete" | "createIndex" | "dropIndex" | "dropIndexes" | "dropCollection" | "findOneAndUpdate" | "findOneAndReplace" | "findOneAndDelete";
export type MongoCommand =
@@ -56,6 +62,7 @@ export type MongoCommand =
| MongoVersionCommand
| ({ kind: "countDocuments" } & MongoCountDocumentsCommand)
| ({ kind: "aggregate" } & MongoAggregateCommand)
| ({ kind: "distinct" } & MongoDistinctCommand)
| ({ kind: "getIndexes" } & MongoGetIndexesCommand)
| ({ kind: "collectionStats" } & MongoCollectionStatsCommand)
| ({ kind: "use" } & MongoUseCommand)
@@ -326,6 +333,35 @@ export function parseMongoAggregateCommand(input: string): MongoAggregateCommand
};
}
export function parseMongoDistinctCommand(input: string): MongoDistinctCommand | null {
const source = input.trim().replace(/;$/, "").trim();
const target = parseCollectionMethodTarget(source, "distinct");
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 || args.length > 2) return null;
const fieldJson = normalizeJsonArgument(args[0] ?? "");
if (!fieldJson) return null;
let field: unknown;
try {
field = JSON.parse(fieldJson);
} catch {
return null;
}
if (typeof field !== "string" || !field.trim()) return null;
if (args.length === 1) return { collection: target.collection, field };
const filter = normalizeJsonArgument(args[1] ?? "");
if (!filter) return null;
return { collection: target.collection, field, filter };
}
export function parseMongoGetIndexesCommand(input: string): MongoGetIndexesCommand | null {
const source = input.trim().replace(/;$/, "").trim();
const target = parseCollectionMethodTarget(source, "getIndexes");
@@ -504,6 +540,10 @@ export function parseMongoCommand(input: string): ParsedMongoCommand | null {
const aggregate = parseMongoAggregateCommand(source);
return aggregate ? { kind: "aggregate", ...aggregate } : null;
},
(source) => {
const distinct = parseMongoDistinctCommand(source);
return distinct ? { kind: "distinct", ...distinct } : null;
},
(source) => {
const getIndexes = parseMongoGetIndexesCommand(source);
return getIndexes ? { kind: "getIndexes", ...getIndexes } : null;
@@ -633,6 +673,15 @@ export function mongoDocumentsToQueryResult(documents: unknown[], executionTimeM
};
}
export function mongoDistinctToQueryResult(field: string, values: unknown[], executionTimeMs: number): QueryResult {
return {
columns: [field],
rows: values.map((value) => [toCellValue(value)]),
affected_rows: values.length,
execution_time_ms: Math.max(0, Math.round(executionTimeMs)),
};
}
export function mongoCountToQueryResult(total: number, executionTimeMs: number): QueryResult {
return {
columns: ["count"],
@@ -883,7 +932,9 @@ function splitMongoSemicolonSeparatedSegments(input: string): MongoTextRange[] {
continue;
}
if (char === "/" && next === "/") {
// `--` is a line comment too: the editor runs Mongo through its SQL language
// mode, which comments with `--` alongside the shell's native `//`.
if ((char === "/" && next === "/") || (char === "-" && next === "-")) {
lineComment = true;
i += 1;
continue;
@@ -980,7 +1031,9 @@ function mongoTopLevelCommandLineStarts(segment: string): number[] {
continue;
}
if (char === "/" && next === "/") {
// `--` is a line comment too: the editor runs Mongo through its SQL language
// mode, which comments with `--` alongside the shell's native `//`.
if ((char === "/" && next === "/") || (char === "-" && next === "-")) {
lineComment = true;
i += 1;
continue;
@@ -1020,19 +1073,68 @@ function pushMongoSegment(segments: MongoTextRange[], source: string, from: numb
if (trimmed) segments.push(trimmed);
}
/**
* Index just past the last code character in `source[start, end)`, treating
* quoted strings and `//` / `--` / block comments as non-code. Trailing
* whitespace and comments sit after the returned index; a comment marker inside
* a string value (`{ note: "a--b" }`) stays code, so it is never mistaken for a
* trailing comment and truncated away.
*/
function mongoCommentAwareBodyEnd(source: string, start: number, end: number): number {
let bodyEnd = start;
let quote: string | null = null;
let i = start;
while (i < end) {
const char = source[i] ?? "";
const next = source[i + 1] ?? "";
if (quote) {
if (char === "\\") {
i += 2;
bodyEnd = Math.min(i, end);
continue;
}
if (char === quote) quote = null;
i += 1;
bodyEnd = i;
continue;
}
if (char === '"' || char === "'") {
quote = char;
i += 1;
bodyEnd = i;
continue;
}
if ((char === "/" && next === "/") || (char === "-" && next === "-")) {
const newline = source.indexOf("\n", i + 2);
i = newline < 0 || newline >= end ? end : newline;
continue;
}
if (char === "/" && next === "*") {
const close = source.indexOf("*/", i + 2);
i = close < 0 || close + 2 > end ? end : close + 2;
continue;
}
if (/\s/.test(char)) {
i += 1;
continue;
}
i += 1;
bodyEnd = i;
}
return bodyEnd;
}
function trimMongoOuterComments(source: string): string {
let value = source.trim();
// Leading comments sit before any string, so a simple regex is safe here.
while (value) {
const next = value.replace(/^(?:\/\/[^\n]*(?:\n|$)|\/\*[\s\S]*?\*\/)\s*/u, "");
const next = value.replace(/^(?:(?:\/\/|--)[^\n]*(?:\n|$)|\/\*[\s\S]*?\*\/)\s*/u, "");
if (next === value) break;
value = next.trimStart();
}
while (value) {
const next = value.replace(/\s*(?:\/\/[^\n]*|\/\*[\s\S]*?\*\/)\s*$/u, "");
if (next === value) break;
value = next.trimEnd();
}
return value.trim();
// Trailing comments need string awareness so a comment marker inside a string
// value near the end is not truncated as if it began a comment.
return value.slice(0, mongoCommentAwareBodyEnd(value, 0, value.length)).trim();
}
function trimMongoOuterCommentRange(source: string, from: number, to: number): MongoTextRange | null {
@@ -1046,7 +1148,7 @@ function trimMongoOuterCommentRange(source: string, from: number, to: number): M
start += value.length - trimmed.length;
continue;
}
const next = value.replace(/^(?:\/\/[^\n]*(?:\n|$)|\/\*[\s\S]*?\*\/)\s*/u, "");
const next = value.replace(/^(?:(?:\/\/|--)[^\n]*(?:\n|$)|\/\*[\s\S]*?\*\/)\s*/u, "");
if (next !== value) {
start += value.length - next.length;
continue;
@@ -1054,20 +1156,10 @@ function trimMongoOuterCommentRange(source: string, from: number, to: number): M
break;
}
while (start < end) {
const value = source.slice(start, end);
const trimmed = value.trimEnd();
if (trimmed !== value) {
end -= value.length - trimmed.length;
continue;
}
const next = value.replace(/\s*(?:\/\/[^\n]*|\/\*[\s\S]*?\*\/)\s*$/u, "");
if (next !== value) {
end -= value.length - next.length;
continue;
}
break;
}
// Trailing comments are found with string awareness (see mongoCommentAwareBodyEnd)
// so a `--`/`//` inside a trailing string value is not treated as a comment.
end = mongoCommentAwareBodyEnd(source, start, end);
while (end > start && /\s/.test(source[end - 1] ?? "")) end -= 1;
if (start >= end) return null;
return {
+16
View File
@@ -14,6 +14,7 @@ import {
evaluateMongoWriteSafety,
mongoCollectionStatsToQueryResult,
mongoCountToQueryResult,
mongoDistinctToQueryResult,
mongoCreateIndexToQueryResult,
mongoDocumentsToQueryResult,
mongoDroppedIndexesToQueryResult,
@@ -2984,6 +2985,21 @@ export const useQueryStore = defineStore("query", () => {
});
break;
}
case "distinct": {
queryExecutionLog("info", "mongo-distinct:start", { traceId, collection: mongoCommand.collection, database: currentDatabase, field: mongoCommand.field });
const result = await api.mongoDistinct(tab.connectionId, currentDatabase, mongoCommand.collection, mongoCommand.field, mongoCommand.filter, executionId);
allResults.push(markQueryResultRowsRaw(annotateMongoResult(mongoDistinctToQueryResult(mongoCommand.field, result.documents, performance.now() - commandStartedAt))));
mongoEditTarget = undefined;
queryExecutionLog("info", "mongo-distinct:done", {
traceId,
collection: mongoCommand.collection,
database: currentDatabase,
field: mongoCommand.field,
valueCount: result.documents.length,
elapsed: elapsed(),
});
break;
}
case "getIndexes": {
queryExecutionLog("info", "mongo-indexes:start", { traceId, collection: mongoCommand.collection, database: currentDatabase });
const indexes = await api.listIndexes(tab.connectionId, currentDatabase, "", mongoCommand.collection);
+32
View File
@@ -767,6 +767,38 @@ pub async fn aggregate_documents(
Ok(MongoDocumentResult { documents, raw_documents: None, extended_documents: Some(extended_documents), total })
}
/// Distinct values of a field, matching the mongo shell's `db.coll.distinct(field, filter)`:
/// array fields contribute their elements rather than the whole array, and the server may
/// answer from an index with a DISTINCT_SCAN. Values are returned in the `documents` slot as
/// bare scalars, which `mongoDocumentsToQueryResult` already renders as a single column.
pub async fn distinct(
client: &Client,
database: &str,
collection: &str,
field: &str,
filter: Option<&str>,
) -> Result<MongoDocumentResult, String> {
if field.trim().is_empty() {
return Err("Distinct field name is required".to_string());
}
let filter_doc: Document = match filter {
Some(f) if !f.trim().is_empty() => {
let json: serde_json::Value = serde_json::from_str(f).map_err(|e| format!("Invalid filter JSON: {e}"))?;
json_filter_to_document(&json)?
}
_ => doc! {},
};
let col = client.database(database).collection::<Document>(collection);
let values = col.distinct(field, filter_doc).await.map_err(|e| e.to_string())?;
let documents = values.iter().map(bson_to_json).collect::<Vec<_>>();
let extended_documents = values.into_iter().map(|value| value.into_relaxed_extjson()).collect::<Vec<_>>();
let total = documents.len() as u64;
Ok(MongoDocumentResult { documents, raw_documents: None, extended_documents: Some(extended_documents), total })
}
pub async fn create_index(
client: &Client,
database: &str,
+18
View File
@@ -257,6 +257,24 @@ pub async fn mongo_aggregate_documents_core(
}
}
pub async fn mongo_distinct_core(
state: &AppState,
connection_id: &str,
database: &str,
collection: &str,
field: &str,
filter: Option<&str>,
) -> Result<MongoDocumentResult, String> {
ensure_document_pool(state, connection_id).await?;
let connections = state.connections.read().await;
match connections.get(connection_id).ok_or("Not found")? {
PoolKind::MongoDb(client) => mongo_driver::distinct(client, database, collection, field, filter).await,
// The legacy agent protocol has no distinct method and no read that could stand in for it.
PoolKind::Agent(_) => Err("MongoDB legacy agent does not support distinct".to_string()),
_ => Err("Not a MongoDB connection".to_string()),
}
}
pub async fn mongo_create_index_core(
state: &AppState,
connection_id: &str,
+1
View File
@@ -487,6 +487,7 @@ async fn main() {
.route("/mongo/server-version", post(routes::mongo::server_version))
.route("/mongo/collection-stats", post(routes::mongo::collection_stats))
.route("/mongo/aggregate-documents", post(routes::mongo::aggregate_documents))
.route("/mongo/distinct", post(routes::mongo::distinct))
.route("/mongo/create-index", post(routes::mongo::create_index))
.route("/mongo/drop-indexes", post(routes::mongo::drop_indexes))
.route("/mongo/insert-document", post(routes::mongo::insert_document))
+31
View File
@@ -130,6 +130,17 @@ pub struct MongoAggregateRequest {
pub execution_id: Option<String>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MongoDistinctRequest {
pub connection_id: String,
pub database: String,
pub collection: String,
pub field: String,
pub filter: Option<String>,
pub execution_id: Option<String>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MongoCreateIndexRequest {
@@ -434,6 +445,26 @@ pub async fn aggregate_documents(
Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?))
}
pub async fn distinct(
State(state): State<Arc<WebState>>,
Json(req): Json<MongoDistinctRequest>,
) -> Result<Json<serde_json::Value>, AppError> {
let result = run_cancellable(
&state,
req.execution_id.clone(),
dbx_core::mongo_ops::mongo_distinct_core(
&state.app,
&req.connection_id,
&req.database,
&req.collection,
&req.field,
req.filter.as_deref(),
),
)
.await?;
Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?))
}
pub async fn create_index(
State(state): State<Arc<WebState>>,
Json(req): Json<MongoCreateIndexRequest>,
+290 -72
View File
@@ -1,134 +1,352 @@
import { strict as assert } from "node:assert";
import { test } from "vitest";
import { buildMongoCompletionItems, getMongoCompletionContext, inferMongoCompletionFields, shouldAutoOpenMongoCompletion } from "../../apps/desktop/src/lib/mongo/mongoCompletion.ts";
import { ACCUMULATORS, EXPRESSION_OPERATORS, PIPELINE_STAGES, PUSH_MODIFIERS, QUERY_OPERATORS, STAGE_OPTION_KEYS, UPDATE_OPERATORS, VALUE_SNIPPETS } from "../../apps/desktop/src/lib/mongo/mongoCompletionTables.ts";
const collections = ["users", "user_events", "order-items"];
const fields = [
{ name: "_id", type: "object" },
{ name: "name", type: "string" },
{ name: "profile.email", type: "string" },
{ name: "createdAt", type: "string" },
{ name: "_id", type: "object" },
{ name: "name", type: "string" },
{ name: "profile.email", type: "string" },
{ name: "createdAt", type: "string" },
];
function labels(text: string, input = {}) {
return buildMongoCompletionItems(text, text.length, input).map((item) => item.label);
return buildMongoCompletionItems(text, text.length, input).map((item) => item.label);
}
test("suggests MongoDB root snippets and methods", () => {
const items = buildMongoCompletionItems("fi", 2);
const items = buildMongoCompletionItems("fi", 2);
assert.ok(items.some((item) => item.type === "function" && item.label === "find" && item.apply === "find({})"));
assert.equal(items.some((item) => item.label === "SELECT"), false);
assert.ok(items.some((item) => item.type === "function" && item.label === "find" && item.apply === "find({})"));
assert.equal(
items.some((item) => item.label === "SELECT"),
false,
);
});
test("suggests collections after db dot", () => {
const items = buildMongoCompletionItems("db.us", "db.us".length, { collections });
const items = buildMongoCompletionItems("db.us", "db.us".length, { collections });
assert.deepEqual(
items.filter((item) => item.type === "table" && item.detail === "collection").map((item) => item.label),
["users", "user_events"],
);
assert.deepEqual(
items.filter((item) => item.type === "table" && item.detail === "collection").map((item) => item.label),
["users", "user_events"],
);
});
test("uses getCollection apply text for unsafe collection names", () => {
const item = buildMongoCompletionItems("db.order", "db.order".length, { collections }).find((candidate) => candidate.label === "order-items");
const item = buildMongoCompletionItems("db.order", "db.order".length, { collections }).find((candidate) => candidate.label === "order-items");
assert.equal(item?.apply, 'getCollection("order-items")');
assert.equal(item?.apply, 'getCollection("order-items")');
});
test("suggests collection methods after direct and getCollection references", () => {
assert.ok(labels("db.users.").includes("find"));
assert.ok(labels('db.getCollection("users").ag').includes("aggregate"));
assert.ok(labels("db.users.").includes("find"));
assert.ok(labels('db.getCollection("users").ag').includes("aggregate"));
});
test("suggests collection stats methods after a collection reference", () => {
const methodLabels = labels("db.users.");
for (const method of ["stats", "dataSize", "storageSize", "totalIndexSize"]) {
assert.ok(methodLabels.includes(method), `expected completion to include ${method}`);
}
const item = buildMongoCompletionItems("db.users.stat", "db.users.stat".length).find((candidate) => candidate.label === "stats");
assert.equal(item?.apply, "stats()");
const methodLabels = labels("db.users.");
for (const method of ["stats", "dataSize", "storageSize", "totalIndexSize"]) {
assert.ok(methodLabels.includes(method), `expected completion to include ${method}`);
}
const item = buildMongoCompletionItems("db.users.stat", "db.users.stat".length).find((candidate) => candidate.label === "stats");
assert.equal(item?.apply, "stats()");
});
test("suggests cursor methods after find result chains", () => {
const allItems = buildMongoCompletionItems("db.characters.find({}).", "db.characters.find({}).".length);
const prefixedItems = buildMongoCompletionItems("db.characters.find({}).li", "db.characters.find({}).li".length);
const formattedChainItems = buildMongoCompletionItems("db.characters.find({\n name: 'Ada'\n})\n .", "db.characters.find({\n name: 'Ada'\n})\n .".length);
const formattedPrefixedItems = buildMongoCompletionItems("db.characters.find({\n name: 'Ada'\n})\n .li", "db.characters.find({\n name: 'Ada'\n})\n .li".length);
const allItems = buildMongoCompletionItems("db.characters.find({}).", "db.characters.find({}).".length);
const prefixedItems = buildMongoCompletionItems("db.characters.find({}).li", "db.characters.find({}).li".length);
const formattedChainItems = buildMongoCompletionItems("db.characters.find({\n name: 'Ada'\n})\n .", "db.characters.find({\n name: 'Ada'\n})\n .".length);
const formattedPrefixedItems = buildMongoCompletionItems("db.characters.find({\n name: 'Ada'\n})\n .li", "db.characters.find({\n name: 'Ada'\n})\n .li".length);
assert.deepEqual(
allItems.map((item) => item.label),
["limit", "skip", "sort"],
);
assert.deepEqual(
prefixedItems.map((item) => item.label),
["limit"],
);
assert.deepEqual(
formattedChainItems.map((item) => item.label),
["limit", "skip", "sort"],
);
assert.deepEqual(
formattedPrefixedItems.map((item) => item.label),
["limit"],
);
assert.deepEqual(
allItems.map((item) => item.label),
["limit", "count", "skip", "sort"],
);
assert.deepEqual(
prefixedItems.map((item) => item.label),
["limit"],
);
assert.deepEqual(
formattedChainItems.map((item) => item.label),
["limit", "count", "skip", "sort"],
);
assert.deepEqual(
formattedPrefixedItems.map((item) => item.label),
["limit"],
);
});
test("offers count only where find().count() actually parses", () => {
// The shell parser accepts count() only as the sole call chained onto find().
assert.ok(labels("db.characters.find({}).").includes("count"));
assert.equal(labels("db.characters.find({}).sort({ name: 1 }).").includes("count"), false);
assert.equal(labels("db.characters.aggregate([]).").includes("count"), false);
});
test("suggests observed fields inside query objects", () => {
const items = buildMongoCompletionItems('db.users.find({ "pro', 'db.users.find({ "pro'.length, { fields });
const email = items.find((item) => item.label === "profile.email");
const items = buildMongoCompletionItems('db.users.find({ "pro', 'db.users.find({ "pro'.length, { fields });
const email = items.find((item) => item.label === "profile.email");
assert.equal(email?.detail, "observed field · string");
assert.equal(email?.type, "column");
assert.equal(email?.apply, '"profile.email": ');
assert.equal(email?.detail, "observed field · string");
assert.equal(email?.type, "column");
assert.equal(email?.apply, '"profile.email": ');
});
test("suggests query fields at object starts and after commas", () => {
const objectStart = buildMongoCompletionItems("db.users.find({", "db.users.find({".length, { fields });
const afterComma = buildMongoCompletionItems("db.users.find({ name: 'Ada', ", "db.users.find({ name: 'Ada', ".length, { fields });
const objectStart = buildMongoCompletionItems("db.users.find({", "db.users.find({".length, { fields });
const afterComma = buildMongoCompletionItems("db.users.find({ name: 'Ada', ", "db.users.find({ name: 'Ada', ".length, { fields });
assert.ok(objectStart.find((item) => item.label === "name" && item.apply === "name: "));
assert.ok(afterComma.find((item) => item.label === "createdAt" && item.apply === "createdAt: "));
assert.ok(objectStart.find((item) => item.label === "name" && item.apply === "name: "));
assert.ok(afterComma.find((item) => item.label === "createdAt" && item.apply === "createdAt: "));
});
test("suggests query operators inside field value objects", () => {
const items = buildMongoCompletionItems("db.users.find({ age: { ", "db.users.find({ age: { ".length, { fields });
const items = buildMongoCompletionItems("db.users.find({ age: { ", "db.users.find({ age: { ".length, { fields });
assert.ok(items.find((item) => item.label === "$gte"));
assert.equal(items.some((item) => item.label === "name"), false);
assert.ok(items.find((item) => item.label === "$gte"));
assert.equal(
items.some((item) => item.label === "name"),
false,
);
});
test("suggests query and update operators", () => {
assert.ok(labels("db.users.find({ age: { $g").includes("$gte"));
assert.ok(labels("db.users.updateOne({}, { $s").includes("$set"));
assert.ok(labels("db.users.find({ age: { $g").includes("$gte"));
assert.ok(labels("db.users.updateOne({}, { $s").includes("$set"));
});
test("suggests aggregation stages inside aggregate pipeline", () => {
const items = buildMongoCompletionItems("db.users.aggregate([{ $m", "db.users.aggregate([{ $m".length);
const items = buildMongoCompletionItems("db.users.aggregate([{ $m", "db.users.aggregate([{ $m".length);
const match = items.find((item) => item.label === "$match");
assert.ok(items.find((item) => item.label === "$match" && item.detail === "aggregation stage"));
assert.equal(match?.info, "aggregation stage");
assert.equal(match?.detail, "Filters documents");
assert.equal(match?.apply, "$match: { ${} }");
});
test("completion context is tolerant of unfinished input", () => {
const context = getMongoCompletionContext('db.getCollection("users").find({ "', 'db.getCollection("users").find({ "'.length);
const context = getMongoCompletionContext('db.getCollection("users").find({ "', 'db.getCollection("users").find({ "'.length);
assert.equal(context.mode, "field");
assert.equal(context.collection, "users");
assert.equal(context.mode, "field");
assert.equal(context.collection, "users");
});
test("method-shaped text inside a string value does not break field completion", () => {
// The call locator must skip string contents; otherwise the ".aggregate(" decoy
// is mistaken for the enclosing call and field completion collapses to nothing.
assert.deepEqual(labels('db.users.find({ note: ".aggregate(", na', { fields }), ["name"]);
assert.deepEqual(labels("db.users.find({ note: '.updateMany(', na", { fields }), ["name"]);
// An escaped quote must not end the string early and re-expose the decoy.
assert.deepEqual(labels('db.users.find({ note: "x\\".aggregate(", na', { fields }), ["name"]);
// A brace or comma hidden in a string value must not corrupt container tracking.
assert.deepEqual(labels('db.users.find({ note: "a}b,c", na', { fields }), ["name"]);
// A string decoy in a sibling field must not stop the next field's operator list.
assert.ok(labels('db.users.find({ note: ".find(", age: { $', { fields }).includes("$gte"));
});
test("does not suggest while the cursor is inside a comment", () => {
// Line comments, anywhere.
assert.deepEqual(labels("// db.users.fi"), []);
assert.deepEqual(labels("db.users.find({})\n// some fi"), []);
assert.deepEqual(labels("db.users.find({ // fi", { fields }), []);
assert.deepEqual(labels("db.users.find({ name: 1 // comment na", { fields }), []);
// Block comments, including the unterminated tail the user is still typing.
assert.deepEqual(labels("/* db.users.fi"), []);
assert.deepEqual(labels("db.users.find({ /* na", { fields }), []);
assert.deepEqual(labels("db.users.find({ age: { // $", { fields }), []);
// A closed comment does not suppress the code that follows it.
assert.deepEqual(labels("db.users.find({ /* skip me */ na", { fields }), ["name"]);
// A comment marker inside a string value is not a comment.
assert.deepEqual(labels('db.users.find({ note: "http://x", na', { fields }), ["name"]);
});
test("treats -- as a line comment, matching the editor's SQL language mode", () => {
// The editor hosts Mongo in SQL mode, so `--` comments like `//` — suppress inside them.
assert.deepEqual(labels("-- db.users.fi"), []);
assert.deepEqual(labels("db.users.find({ -- fi", { fields }), []);
assert.deepEqual(labels("db.users.find({ age: { -- $", { fields }), []);
// Code after a closed `--` line still completes.
assert.deepEqual(labels("db.users.find({ name: 1, -- note\n na", { fields }), ["name"]);
// A double dash inside a string value is not a comment.
assert.deepEqual(labels('db.users.find({ note: "a--b", na', { fields }), ["name"]);
});
test("method-shaped text inside a comment does not break field completion", () => {
assert.deepEqual(labels("db.users.find({ /* .aggregate( */ na", { fields }), ["name"]);
assert.deepEqual(labels("db.users.find({\n // .updateMany(\n na", { fields }), ["name"]);
// Braces and commas inside a comment must not pop the container stack.
assert.deepEqual(labels("db.users.find({ /* } , */ na", { fields }), ["name"]);
// Operators are still offered after an inline comment.
assert.ok(labels("db.users.find({ age: { /* x */ $", { fields }).includes("$gte"));
});
test("auto trigger opens for useful MongoDB characters only", () => {
assert.equal(shouldAutoOpenMongoCompletion("db.", "db.".length), true);
assert.equal(shouldAutoOpenMongoCompletion("db.users.find({ $", "db.users.find({ $".length), true);
assert.equal(shouldAutoOpenMongoCompletion("db.users.find({", "db.users.find({".length), true);
assert.equal(shouldAutoOpenMongoCompletion("db.", "db.".length), true);
assert.equal(shouldAutoOpenMongoCompletion("db.users.find({ $", "db.users.find({ $".length), true);
assert.equal(shouldAutoOpenMongoCompletion("db.users.find({", "db.users.find({".length), true);
});
test("keeps query and update operators in their own positions", () => {
const filter = labels("db.users.find({ age: { $");
assert.ok(filter.includes("$gte"));
assert.equal(filter.includes("$set"), false, "update operators are not valid in a filter");
const update = labels("db.users.updateOne({}, { $");
assert.ok(update.includes("$set"));
assert.equal(update.includes("$gte"), false, "query operators are not valid in an update document");
});
test("suggests fields under an update operator and array modifiers under $push", () => {
assert.deepEqual(labels("db.users.updateOne({}, { $set: { ", { fields }), ["_id", "createdAt", "name", "profile.email"]);
const modifiers = labels("db.users.updateOne({}, { $push: { tags: { ", { fields });
assert.ok(modifiers.includes("$each"));
assert.equal(modifiers.includes("$set"), false);
});
test("suggests fields inside a $match stage rather than operators", () => {
const items = buildMongoCompletionItems("db.users.aggregate([{ $match: { na", "db.users.aggregate([{ $match: { na".length, { fields });
assert.deepEqual(
items.map((item) => item.label),
["name"],
);
});
test("suggests query operators against a field inside a $match stage", () => {
const items = labels("db.users.aggregate([{ $match: { age: { $g", { fields });
assert.ok(items.includes("$gte"));
assert.equal(items.includes("$group"), false, "a stage is not valid inside a $match constraint");
});
test("suggests accumulators, not stages, for a $group output field", () => {
const items = labels('db.users.aggregate([{ $group: { _id: "$name", total: { $', { fields });
assert.ok(items.includes("$sum"));
assert.ok(items.includes("$avg"));
assert.equal(items.includes("$match"), false, "stages are only valid at pipeline level");
});
test("suggests quoted field references in aggregation expression positions", () => {
const groupKey = buildMongoCompletionItems('db.users.aggregate([{ $group: { _id: "$', 'db.users.aggregate([{ $group: { _id: "$'.length, { fields });
const email = groupKey.find((item) => item.label === "$profile.email");
assert.equal(email?.apply, '"$profile.email"');
assert.equal(email?.detail, "field reference · string");
// Also offered unquoted, where accepting the item supplies the quotes.
const projection = buildMongoCompletionItems("db.users.aggregate([{ $project: { upper: ", "db.users.aggregate([{ $project: { upper: ".length, { fields });
assert.equal(projection.find((item) => item.label === "$name")?.apply, '"$name"');
});
test("suggests $lookup option keys and collections for its from option", () => {
assert.deepEqual(labels("db.users.aggregate([{ $lookup: { fr", { collections }), ["from"]);
const from = buildMongoCompletionItems('db.users.aggregate([{ $lookup: { from: "us', 'db.users.aggregate([{ $lookup: { from: "us'.length, { collections });
assert.deepEqual(
from.map((item) => item.label),
["users", "user_events"],
);
assert.equal(from[0]?.apply, '"users"', "a collection in string position keeps its quotes");
});
test("suggests stages only at pipeline level, including nested pipelines", () => {
assert.ok(labels("db.users.aggregate([{ $m").includes("$match"));
assert.ok(labels("db.users.aggregate([{ $facet: { recent: [{ $m").includes("$match"));
assert.ok(labels('db.users.aggregate([{ $lookup: { from: "orders", pipeline: [{ $m').includes("$match"));
// A plain value array is not a pipeline.
assert.equal(labels("db.users.aggregate([{ $match: { tags: { $in: [{ $m").includes("$match"), false);
});
test("suggests values, not fields, after a filter key", () => {
const items = labels("db.users.find({ _id: ", { fields });
assert.ok(items.includes("ObjectId"));
assert.ok(items.includes("ISODate"));
assert.equal(items.includes("name"), false, "field names are only valid in key position");
});
test("stays quiet inside string values, comments and unmodelled arguments", () => {
assert.deepEqual(labels('db.users.find({ name: "Ad', { fields }), []);
assert.deepEqual(labels("// db.users.fi", { fields }), []);
assert.deepEqual(labels('db.users.find({ _id: ObjectId("6a04', { fields }), []);
assert.deepEqual(labels("db.users.updateOne({}, { $set: {} }, { up", { fields }), []);
});
test("suggests only helpers the shell parser accepts", () => {
const methodLabels = labels("db.users.");
assert.ok(methodLabels.includes("count"));
assert.ok(methodLabels.includes("drop"));
assert.ok(methodLabels.includes("distinct"));
// Suggesting a helper DBX cannot run just hands the user a command that fails.
for (const unsupported of ["bulkWrite", "estimatedDocumentCount", "replaceOne"]) {
assert.equal(methodLabels.includes(unsupported), false, `${unsupported} is not executable`);
}
// Cursor methods are not collection methods.
assert.equal(methodLabels.includes("limit"), false);
});
test("completes both arguments of distinct", () => {
const method = buildMongoCompletionItems("db.users.dist", "db.users.dist".length).find((item) => item.label === "distinct");
assert.equal(method?.apply, 'distinct("${field}")');
// First argument names a field, so it is completed as a quoted path.
const fieldArg = buildMongoCompletionItems('db.users.distinct("pro', 'db.users.distinct("pro'.length, { fields });
assert.deepEqual(
fieldArg.map((item) => item.label),
["profile.email"],
);
assert.equal(fieldArg[0]?.apply, '"profile.email"');
// Second argument is a filter, so it behaves like find()'s.
const filterArg = labels('db.users.distinct("name", { ', { fields });
assert.deepEqual(filterArg, ["_id", "createdAt", "name", "profile.email"]);
assert.ok(labels('db.users.distinct("name", { age: { $g', { fields }).includes("$gte"));
});
test("suggests the use and db.version commands", () => {
assert.equal(buildMongoCompletionItems("us", 2).find((item) => item.label === "use")?.apply, "use ${database}");
// Database helpers stay reachable after `db.`, alongside the collection names.
assert.ok(labels("db.vers", { collections }).includes("version"));
assert.equal(labels("db.getColl", { collections }).includes("getCollection"), true);
});
test("ranks everyday operators above the long tail", () => {
const queryOperators = labels("db.users.find({ age: { $").slice(0, 10);
assert.ok(queryOperators.includes("$eq"));
assert.ok(queryOperators.includes("$in"));
assert.equal(queryOperators.includes("$bitsAllClear"), false);
const stages = labels("db.users.aggregate([{ $").slice(0, 10);
assert.ok(stages.includes("$match"));
assert.ok(stages.includes("$group"));
assert.equal(stages.includes("$planCacheStats"), false);
});
test("snippet templates use placeholder syntax CodeMirror actually honours", () => {
const templates = [...QUERY_OPERATORS, ...UPDATE_OPERATORS, ...PUSH_MODIFIERS, ...PIPELINE_STAGES, ...ACCUMULATORS, ...EXPRESSION_OPERATORS, ...VALUE_SNIPPETS, ...Object.values(STAGE_OPTION_KEYS).flat()];
assert.ok(templates.length > 200);
for (const { label, apply } of templates) {
// `${10}` reads as tab stop number 10 with no text, so the default is silently
// dropped on accept. Numeric defaults have to be written literally.
assert.equal(/\$\{\d+\}/.test(apply), false, `${label} has a numeric placeholder that would insert nothing: ${apply}`);
// Placeholder names cannot nest braces — the parser stops at the first `}`.
assert.equal(/\$\{[^{}]*\{/.test(apply), false, `${label} has a nested brace in a placeholder: ${apply}`);
}
});
test("infers dotted MongoDB fields from sampled documents", () => {
const inferred = inferMongoCompletionFields([
{ _id: "1", profile: { email: "a@example.com" }, tags: ["a"] },
{ _id: "2", profile: { age: 3 }, tags: [{ label: "vip" }] },
]);
const inferred = inferMongoCompletionFields([
{ _id: "1", profile: { email: "a@example.com" }, tags: ["a"] },
{ _id: "2", profile: { age: 3 }, tags: [{ label: "vip" }] },
]);
assert.ok(inferred.find((field) => field.name === "profile.email" && field.type === "string"));
assert.ok(inferred.find((field) => field.name === "profile.age" && field.type === "number"));
assert.ok(inferred.find((field) => field.name === "tags.label" && field.type === "string"));
assert.ok(inferred.find((field) => field.name === "profile.email" && field.type === "string"));
assert.ok(inferred.find((field) => field.name === "profile.age" && field.type === "number"));
assert.ok(inferred.find((field) => field.name === "tags.label" && field.type === "string"));
});
+85 -1
View File
@@ -6,12 +6,14 @@ import {
mongoAggregateWriteStage,
mongoCollectionStatsToQueryResult,
mongoCountToQueryResult,
mongoDistinctToQueryResult,
mongoDocumentsToQueryResult,
mongoIndexesToQueryResult,
parseMongoAggregateCommand,
parseMongoCollectionStatsCommand,
parseMongoCommand,
parseMongoCountDocumentsCommand,
parseMongoDistinctCommand,
parseMongoFindCommand,
parseMongoFindOneCommand,
parseMongoFindOneAndUpdateCommand,
@@ -239,6 +241,37 @@ test("parseMongoCommand normalizes outer comments around a command", () => {
});
});
test("parseMongoCommand strips SQL-style -- comments the editor inserts", () => {
// The editor runs Mongo through its SQL language mode, whose line comment is `--`.
assert.equal(parseMongoCommand("-- current database\ndb.users.find({})")?.command.kind, "find");
assert.equal(parseMongoCommand("db.users.find({}) -- run this one")?.command.kind, "find");
assert.equal(parseMongoCommand("/* header */ db.users.find({}) -- trailer")?.command.kind, "find");
// Native // comments still work alongside --.
assert.equal(parseMongoCommand("// keep\ndb.users.find({})")?.command.kind, "find");
});
test("parseMongoCommand keeps comment markers that live inside string values", () => {
// A `--` or `//` inside a string must not be trimmed as a trailing comment.
const dash = parseMongoFindCommand('db.users.find({ note: "a--b" })');
assert.ok(dash);
assert.deepEqual(JSON.parse(dash.filter), { note: "a--b" });
const slash = parseMongoFindCommand('db.users.find({ url: "http://x" })');
assert.ok(slash);
assert.deepEqual(JSON.parse(slash.filter), { url: "http://x" });
});
test("splitMongoCommands splits statements separated by -- comment lines", () => {
const commands = splitMongoCommands(`
-- seed two rows
db.users.insertOne({ name: "A" });
db.users.insertOne({ name: "B" }) -- second
`);
assert.deepEqual(
commands.map(({ command }) => command.kind),
["insert", "insert"],
);
});
test("parseMongoWriteCommand accepts unquoted insert and update commands", () => {
assert.deepEqual(parseMongoWriteCommand("db.products.insertOne({name: 'demo', price: 1})"), {
kind: "insert",
@@ -422,6 +455,57 @@ test("parseMongoAggregateCommand normalises ObjectId arguments with either quote
}
});
test("parseMongoDistinctCommand parses a field with an optional filter", () => {
assert.deepEqual(parseMongoDistinctCommand('db.products.distinct("category")'), {
collection: "products",
field: "category",
});
assert.deepEqual(parseMongoDistinctCommand("db.products.distinct('category', { active: true })"), {
collection: "products",
field: "category",
filter: '{ "active": true }',
});
assert.deepEqual(parseMongoDistinctCommand('db.getCollection("audit.logs").distinct("level");'), {
collection: "audit.logs",
field: "level",
});
});
test("parseMongoDistinctCommand normalises shell constructors in its filter", () => {
const command = parseMongoDistinctCommand("db.orders.distinct(\"status\", { _id: ObjectId('507f1f77bcf86cd799439011') })");
assert.ok(command);
assert.deepEqual(JSON.parse(command.filter || "{}"), { _id: { $oid: "507f1f77bcf86cd799439011" } });
});
test("parseMongoDistinctCommand requires a non-empty string field", () => {
assert.equal(parseMongoDistinctCommand("db.products.distinct()"), null);
assert.equal(parseMongoDistinctCommand('db.products.distinct("")'), null);
assert.equal(parseMongoDistinctCommand("db.products.distinct({ category: 1 })"), null);
assert.equal(parseMongoDistinctCommand("db.products.distinct(category)"), null);
// Cursor chaining and extra arguments are rejected rather than silently ignored.
assert.equal(parseMongoDistinctCommand('db.products.distinct("category").limit(5)'), null);
assert.equal(parseMongoDistinctCommand('db.products.distinct("category", {}, {})'), null);
});
test("parseMongoCommand tags distinct with its own kind", () => {
assert.deepEqual(parseMongoCommand('db.products.distinct("category")')?.command, {
kind: "distinct",
collection: "products",
field: "category",
});
});
test("mongoDistinctToQueryResult names the column after the field", () => {
assert.deepEqual(mongoDistinctToQueryResult("category", ["books", "toys", null], 4), {
columns: ["category"],
rows: [["books"], ["toys"], [null]],
affected_rows: 3,
execution_time_ms: 4,
});
// ObjectId values are displayed, not dumped as raw extended JSON.
assert.deepEqual(mongoDistinctToQueryResult("owner", [{ $oid: "507f1f77bcf86cd799439011" }], 0).rows, [["507f1f77bcf86cd799439011"]]);
});
test("parseMongoGetIndexesCommand parses collection index commands", () => {
assert.deepEqual(parseMongoGetIndexesCommand("db.web_log.getIndexes();"), {
collection: "web_log",
@@ -548,7 +632,7 @@ test("splitMongoCommandRanges preserve document offsets for newline-separated co
},
{
from: source.indexOf('db.getCollection("entries")'),
to: source.indexOf(' .limit(5)') + " .limit(5)".length,
to: source.indexOf(" .limit(5)") + " .limit(5)".length,
text: 'db.getCollection("entries")\n .find({ status: "open" })\n .limit(5)',
kind: "find",
},
+54 -1
View File
@@ -896,6 +896,11 @@ export async function executeQuery(config: ConnectionConfig, sql: string, option
const result = await withTimeout(mongoAggregateDocuments(config, aggregate.collection, aggregate.pipeline, resolveMaxRows(options)), resolveTimeoutMs(options));
return mongoDocumentsToQueryResult(result.documents.slice(0, resolveMaxRows(options)), result.total);
}
const distinct = parseMongoDistinctCommand(sql);
if (distinct) {
const result = await withTimeout(mongoDistinct(config, distinct.collection, distinct.field, distinct.filter), resolveTimeoutMs(options));
return mongoDistinctToQueryResult(distinct.field, result.documents.slice(0, resolveMaxRows(options)));
}
const getIndexes = parseMongoGetIndexesCommand(sql);
if (getIndexes) {
const result = await withTimeout(mongoAggregateDocuments(config, getIndexes.collection, '[{"$indexStats":{}}]', resolveMaxRows(options)), resolveTimeoutMs(options));
@@ -928,7 +933,7 @@ export async function executeQuery(config: ConnectionConfig, sql: string, option
return { columns: [], rows: [], row_count: result.affectedRows };
}
throw new Error(
'Use MongoDB shell-style commands, for example: db.projects.find({}).limit(100), db.version(), db.projects.countDocuments({}), db.projects.count({}), db.projects.getIndexes(), db.projects.dataSize(), db.projects.storageSize(1024), db.projects.totalIndexSize(), db.projects.stats(), db.projects.createIndex({...}), db.projects.dropIndex("name"), db.projects.dropIndexes(), db.projects.drop(), db.projects.insertOne({...}), db.projects.updateOne({...}, {$set: {...}}), or db.projects.deleteOne({...})',
'Use MongoDB shell-style commands, for example: db.projects.find({}).limit(100), db.version(), db.projects.countDocuments({}), db.projects.count({}), db.projects.distinct("status"), db.projects.getIndexes(), db.projects.dataSize(), db.projects.storageSize(1024), db.projects.totalIndexSize(), db.projects.stats(), db.projects.createIndex({...}), db.projects.dropIndex("name"), db.projects.dropIndexes(), db.projects.drop(), db.projects.insertOne({...}), db.projects.updateOne({...}, {$set: {...}}), or db.projects.deleteOne({...})',
);
}
if (isDirectQueryType(config.db_type)) {
@@ -1231,6 +1236,26 @@ async function mongoAggregateDocuments(config: ConnectionConfig, collection: str
});
}
async function mongoDistinct(config: ConnectionConfig, collection: string, field: string, filter?: string): Promise<MongoDocumentResult> {
return bridgeDataRequest<MongoDocumentResult>("/data/mongo/distinct", {
connection_id: config.id,
connection_name: config.name,
database: config.database || "",
collection,
field,
filter,
});
}
/** distinct returns bare values, so the single column is named after the field. */
export function mongoDistinctToQueryResult(field: string, values: unknown[]): QueryResult {
return {
columns: [field],
rows: values.map((value) => ({ [field]: toCellValue(value) })),
row_count: values.length,
};
}
export function mongoCollectionStatsToQueryResult(metric: MongoCollectionStatsMetric, stats: Record<string, unknown>): QueryResult {
if (metric === "stats") {
const columns = ["count", "size", "avgObjSize", "storageSize", "totalIndexSize", "nindexes"];
@@ -1315,6 +1340,12 @@ interface MongoAggregateCommand {
pipeline: string;
}
interface MongoDistinctCommand {
collection: string;
field: string;
filter?: string;
}
interface MongoGetIndexesCommand {
collection: string;
}
@@ -1416,6 +1447,28 @@ export function parseMongoAggregateCommand(input: string): MongoAggregateCommand
return Array.isArray(JSON.parse(pipeline)) ? { collection: target.collection, pipeline } : null;
}
export function parseMongoDistinctCommand(input: string): MongoDistinctCommand | null {
const source = input.trim().replace(/;$/, "").trim();
const target = parseCollectionMethodTarget(source, "distinct");
if (!target) return null;
const args = parseMethodArgs(source, target.methodCallIndex);
if (!args || args.length < 1 || args.length > 2) return null;
const fieldJson = normalizeJsonArgument(args[0] ?? "");
if (!fieldJson) return null;
let field: unknown;
try {
field = JSON.parse(fieldJson);
} catch {
return null;
}
if (typeof field !== "string" || !field.trim()) return null;
if (args.length === 1) return { collection: target.collection, field };
const filter = normalizeJsonArgument(args[1] ?? "");
return filter ? { collection: target.collection, field, filter } : null;
}
export function parseMongoGetIndexesCommand(input: string): MongoGetIndexesCommand | null {
const source = input.trim().replace(/;$/, "").trim();
const target = parseCollectionMethodTarget(source, "getIndexes");
+18 -1
View File
@@ -6,10 +6,12 @@ import {
evaluateMongoWriteSafety,
inferMongoColumns,
mongoCollectionStatsToQueryResult,
mongoDistinctToQueryResult,
mongoDocumentsToQueryResult,
parseMongoAggregateCommand,
parseMongoCollectionStatsCommand,
parseMongoCountDocumentsCommand,
parseMongoDistinctCommand,
parseMongoFindCommand,
parseMongoGetIndexesCommand,
parseMongoVersionCommand,
@@ -267,6 +269,21 @@ export async function executeQuery(config: ConnectionConfig, sql: string, option
const result = (await res.json()) as { documents: unknown[]; total: number };
return mongoDocumentsToQueryResult(result.documents.slice(0, options?.maxRows ?? result.documents.length), result.total);
}
const distinct = parseMongoDistinctCommand(sql);
if (distinct) {
const res = await apiFetch("/api/mongo/distinct", {
method: "POST",
body: JSON.stringify({
connectionId: config.id,
database: config.database || "",
collection: distinct.collection,
field: distinct.field,
filter: distinct.filter,
}),
});
const result = (await res.json()) as { documents: unknown[]; total: number };
return mongoDistinctToQueryResult(distinct.field, result.documents.slice(0, options?.maxRows ?? result.documents.length));
}
const getIndexes = parseMongoGetIndexesCommand(sql);
if (getIndexes) {
const res = await apiFetch("/api/mongo/aggregate-documents", {
@@ -310,7 +327,7 @@ export async function executeQuery(config: ConnectionConfig, sql: string, option
return { columns: [], rows: [], row_count: result.affectedRows };
}
throw new Error(
'Use MongoDB shell-style commands, for example: db.projects.find({}).limit(100), db.version(), db.projects.countDocuments({}), db.projects.count({}), db.projects.getIndexes(), db.projects.dataSize(), db.projects.storageSize(1024), db.projects.totalIndexSize(), db.projects.stats(), db.projects.createIndex({...}), db.projects.dropIndex("name"), db.projects.dropIndexes(), db.projects.drop(), db.projects.insertOne({...}), db.projects.updateOne({...}, {$set: {...}}), or db.projects.deleteOne({...})',
'Use MongoDB shell-style commands, for example: db.projects.find({}).limit(100), db.version(), db.projects.countDocuments({}), db.projects.count({}), db.projects.distinct("status"), db.projects.getIndexes(), db.projects.dataSize(), db.projects.storageSize(1024), db.projects.totalIndexSize(), db.projects.stats(), db.projects.createIndex({...}), db.projects.dropIndex("name"), db.projects.dropIndexes(), db.projects.drop(), db.projects.insertOne({...}), db.projects.updateOne({...}, {$set: {...}}), or db.projects.deleteOne({...})',
);
}
const res = await apiFetch("/api/query/execute", {
+40
View File
@@ -98,6 +98,16 @@ struct MongoAggregateDocumentsRequest {
max_rows: Option<usize>,
}
#[derive(Deserialize)]
struct MongoDistinctRequest {
connection_name: String,
connection_id: Option<String>,
database: Option<String>,
collection: String,
field: String,
filter: Option<String>,
}
#[derive(Deserialize)]
struct MongoCreateIndexRequest {
connection_name: String,
@@ -234,6 +244,8 @@ pub fn start(app_handle: AppHandle, state: Arc<AppState>, data_dir: PathBuf) {
handle_mongo_collection_stats_data(&st, body, &mut stream).await;
} else if first_line.starts_with("POST /data/mongo/aggregate-documents") {
handle_mongo_aggregate_documents_data(&st, body, &mut stream).await;
} else if first_line.starts_with("POST /data/mongo/distinct") {
handle_mongo_distinct_data(&st, body, &mut stream).await;
} else if first_line.starts_with("POST /data/mongo/create-index") {
handle_mongo_create_index_data(&st, body, &mut stream).await;
} else if first_line.starts_with("POST /data/mongo/drop-indexes") {
@@ -660,6 +672,34 @@ async fn handle_mongo_aggregate_documents_data(state: &Arc<AppState>, body: &str
}
}
async fn handle_mongo_distinct_data(state: &Arc<AppState>, body: &str, stream: &mut tokio::net::TcpStream) {
let req: MongoDistinctRequest = match serde_json::from_str(body) {
Ok(r) => r,
Err(_) => {
respond_error(stream, "400 Bad Request", "Invalid JSON").await;
return;
}
};
let Some((connection_id, database)) =
resolve_mongo_target(state, req.connection_id.as_deref(), &req.connection_name, req.database, stream).await
else {
return;
};
match dbx_core::mongo_ops::mongo_distinct_core(
state,
&connection_id,
&database,
&req.collection,
&req.field,
req.filter.as_deref(),
)
.await
{
Ok(result) => respond_json(stream, &result).await,
Err(e) => respond_error(stream, "500 Internal Server Error", &e).await,
}
}
async fn handle_mongo_create_index_data(state: &Arc<AppState>, body: &str, stream: &mut tokio::net::TcpStream) {
let req: MongoCreateIndexRequest = match serde_json::from_str(body) {
Ok(r) => r,
+26
View File
@@ -221,6 +221,32 @@ pub async fn mongo_aggregate_documents(
.await
}
#[tauri::command]
pub async fn mongo_distinct(
state: State<'_, Arc<AppState>>,
connection_id: String,
database: String,
collection: String,
field: String,
filter: Option<String>,
execution_id: Option<String>,
) -> Result<MongoDocumentResult, String> {
let app = state.inner().clone();
run_cancellable(
&app,
execution_id,
dbx_core::mongo_ops::mongo_distinct_core(
&app,
&connection_id,
&database,
&collection,
&field,
filter.as_deref(),
),
)
.await
}
#[tauri::command]
pub async fn mongo_create_index(
state: State<'_, Arc<AppState>>,
+1
View File
@@ -1302,6 +1302,7 @@ pub fn run() {
commands::mongo_cmd::mongo_server_version,
commands::mongo_cmd::mongo_collection_stats,
commands::mongo_cmd::mongo_aggregate_documents,
commands::mongo_cmd::mongo_distinct,
commands::mongo_cmd::mongo_create_index,
commands::mongo_cmd::mongo_drop_indexes,
commands::document_cmd::document_insert_document,