feat: improve MongoDB query assistance
This commit is contained in:
parent
8b6eb353db
commit
39ced46282
|
|
@ -10,12 +10,14 @@ import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomC
|
|||
import { copyToClipboard } from "@/lib/clipboard";
|
||||
import { resolveExecutableSql, type SqlExecutionSnapshot } from "@/lib/sqlExecutionTarget";
|
||||
import { formatSqlText, type SqlFormatDialect } from "@/lib/sqlFormatter";
|
||||
import { formatMongoShellText } from "@/lib/mongoFormatter";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import { useTheme } from "@/composables/useTheme";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { buildSqlCompletionItemsFromContext, getSqlFunctionSignatureHelp, getSqlCompletionContext, getSqlCompletionResultValidFor, isSqlLikeCompletionStatement, recordCompletionSelection, shouldAutoOpenSqlCompletion, extractCteDefinitions } from "@/lib/sqlCompletion";
|
||||
import { buildElasticsearchCompletionItemsFromContext, getElasticsearchCompletionContext, getElasticsearchCompletionResultValidFor, shouldAutoOpenElasticsearchCompletion, type ElasticsearchCompletionItem } from "@/lib/elasticsearchCompletion";
|
||||
import { buildMongoCompletionItemsFromContext, getMongoCompletionContext, getMongoCompletionResultValidFor, shouldAutoOpenMongoCompletion, type MongoCompletionItem } from "@/lib/mongoCompletion";
|
||||
import { extractIdentifierAt, isSqlKeyword, matchTable } from "@/lib/sqlNavigation";
|
||||
import { lineColumnToOffset, parseSqlErrorLocation } from "@/lib/sqlDiagnostics";
|
||||
import {
|
||||
|
|
@ -829,7 +831,7 @@ async function formatCurrentSql() {
|
|||
if (!source.trim()) return;
|
||||
|
||||
try {
|
||||
const formatted = await formatSqlText(source, props.formatDialect ?? props.dialect ?? "generic", settingsStore.editorSettings.sqlFormatter);
|
||||
const formatted = props.databaseType === "mongodb" ? formatMongoShellText(source, settingsStore.editorSettings.sqlFormatter) : await formatSqlText(source, props.formatDialect ?? props.dialect ?? "generic", settingsStore.editorSettings.sqlFormatter);
|
||||
if (view.value !== currentView || currentView.state !== originalState || currentView.state.sliceDoc(from, to) !== source) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -904,7 +906,7 @@ function unregisterTableReferenceDropListener() {
|
|||
let completionEpoch = 0;
|
||||
let completionDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
type QueryCompletionItem = SqlCompletionItem | ElasticsearchCompletionItem | RedisCompletionItem;
|
||||
type QueryCompletionItem = SqlCompletionItem | ElasticsearchCompletionItem | RedisCompletionItem | MongoCompletionItem;
|
||||
|
||||
function buildCompletionResult(items: QueryCompletionItem[], from: number, validFor?: RegExp) {
|
||||
if (items.length === 0) return null;
|
||||
|
|
@ -1031,10 +1033,50 @@ async function provideRedisCompletions(currentState: import("@codemirror/state")
|
|||
};
|
||||
}
|
||||
|
||||
async function provideMongoCompletions(currentState: import("@codemirror/state").EditorState, position: number, explicit: boolean) {
|
||||
if (!props.connectionId) return null;
|
||||
const epoch = ++completionEpoch;
|
||||
const fullDoc = currentState.doc.toString();
|
||||
if (!explicit && !shouldAutoOpenMongoCompletion(fullDoc, position)) return null;
|
||||
|
||||
const completionContext = getMongoCompletionContext(fullDoc, position);
|
||||
let collections: string[] = [];
|
||||
let fields: Awaited<ReturnType<typeof connectionStore.listMongoCompletionFields>> = [];
|
||||
|
||||
if (props.database && completionContext.mode === "collection") {
|
||||
try {
|
||||
collections = await connectionStore.listMongoCompletionCollections(props.connectionId, props.database);
|
||||
} catch {
|
||||
collections = [];
|
||||
}
|
||||
}
|
||||
|
||||
if (props.database && completionContext.mode === "field" && completionContext.collection) {
|
||||
try {
|
||||
fields = await connectionStore.listMongoCompletionFields(props.connectionId, props.database, completionContext.collection);
|
||||
} catch {
|
||||
fields = [];
|
||||
}
|
||||
}
|
||||
|
||||
if (epoch !== completionEpoch) return null;
|
||||
|
||||
const items = buildMongoCompletionItemsFromContext(completionContext, { collections, fields });
|
||||
if (items.length === 0) return null;
|
||||
return {
|
||||
from: completionContext.from,
|
||||
options: items.map((item) => completionOptionForItem(item)),
|
||||
validFor: getMongoCompletionResultValidFor(),
|
||||
};
|
||||
}
|
||||
|
||||
async function provideSqlCompletions(currentState: import("@codemirror/state").EditorState, position: number, explicit: boolean) {
|
||||
if (imeCompositionActive || view.value?.compositionStarted || view.value?.composing) return null;
|
||||
if (!props.connectionId) return null;
|
||||
const fullDoc = currentState.doc.toString();
|
||||
if (props.databaseType === "mongodb") {
|
||||
return provideMongoCompletions(currentState, position, explicit);
|
||||
}
|
||||
if (props.databaseType === "elasticsearch") {
|
||||
if (!isSqlLikeCompletionStatement(fullDoc, position)) {
|
||||
return provideElasticsearchCompletions(currentState, position, explicit);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,411 @@
|
|||
export type MongoCompletionMode = "root" | "collection" | "method" | "cursorMethod" | "field" | "operator" | "stage";
|
||||
|
||||
export interface MongoCompletionField {
|
||||
name: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
export interface MongoCompletionItem {
|
||||
label: string;
|
||||
type: "column" | "function" | "keyword" | "snippet" | "table";
|
||||
detail?: string;
|
||||
info?: string;
|
||||
apply?: string;
|
||||
boost: number;
|
||||
}
|
||||
|
||||
export interface MongoCompletionContext {
|
||||
mode: MongoCompletionMode;
|
||||
prefix: string;
|
||||
from: number;
|
||||
collection?: string;
|
||||
}
|
||||
|
||||
export interface MongoCompletionInput {
|
||||
collections?: string[];
|
||||
fields?: MongoCompletionField[];
|
||||
}
|
||||
|
||||
const COLLECTION_METHODS = [
|
||||
{ label: "find", detail: "Query matching documents", apply: "find({})" },
|
||||
{ label: "findOne", detail: "Query one matching document", apply: "findOne({})" },
|
||||
{ label: "aggregate", detail: "Run an aggregation pipeline", apply: "aggregate([])" },
|
||||
{ label: "countDocuments", detail: "Count matching documents", apply: "countDocuments({})" },
|
||||
{ label: "distinct", detail: "Return distinct field values", apply: 'distinct("${field}", {})' },
|
||||
{ label: "insertOne", detail: "Insert one document", apply: "insertOne({})" },
|
||||
{ label: "insertMany", detail: "Insert multiple documents", apply: "insertMany([{}])" },
|
||||
{ label: "updateOne", detail: "Update one matching document", apply: "updateOne({}, { $set: {} })" },
|
||||
{ label: "updateMany", detail: "Update all matching documents", apply: "updateMany({}, { $set: {} })" },
|
||||
{ label: "deleteOne", detail: "Delete one matching document", apply: "deleteOne({})" },
|
||||
{ label: "deleteMany", detail: "Delete all matching documents", apply: "deleteMany({})" },
|
||||
{ label: "getIndexes", detail: "List collection indexes", apply: "getIndexes()" },
|
||||
{ label: "createIndex", detail: "Create an index", apply: "createIndex({ ${field}: 1 })" },
|
||||
] as const;
|
||||
|
||||
const CURSOR_METHODS = [
|
||||
{ label: "sort", detail: "Sort cursor results", apply: "sort({ ${field}: 1 })" },
|
||||
{ label: "limit", detail: "Limit cursor results", apply: "limit(100)" },
|
||||
{ label: "skip", detail: "Skip cursor results", apply: "skip(0)" },
|
||||
] as const;
|
||||
|
||||
const ROOT_SNIPPETS = [
|
||||
{ label: "db.collection.find", detail: "Find documents", apply: "db.${collection}.find({})" },
|
||||
{ label: "db.collection.aggregate", detail: "Aggregation pipeline", apply: "db.${collection}.aggregate([\n { $match: {} }\n])" },
|
||||
{ label: "db.getCollection", detail: "Reference a collection by name", apply: 'db.getCollection("${collection}")' },
|
||||
] as const;
|
||||
|
||||
const FIELD_SNIPPETS = [
|
||||
{ label: "ObjectId", detail: "MongoDB ObjectId value", apply: 'ObjectId("${id}")' },
|
||||
{ label: "ISODate", detail: "MongoDB ISODate value", apply: 'ISODate("${date}")' },
|
||||
{ label: "$regex", detail: "Regular expression match", apply: "$regex: \"${pattern}\"" },
|
||||
{ label: "$in", detail: "Match any value in array", apply: "$in: []" },
|
||||
{ label: "$gte", detail: "Greater than or equal", apply: "$gte: ${value}" },
|
||||
{ label: "$lte", detail: "Less than or equal", apply: "$lte: ${value}" },
|
||||
] as const;
|
||||
|
||||
const QUERY_OPERATORS = ["$eq", "$ne", "$gt", "$gte", "$lt", "$lte", "$in", "$nin", "$exists", "$regex", "$and", "$or", "$nor", "$not", "$elemMatch"];
|
||||
const UPDATE_OPERATORS = ["$set", "$unset", "$inc", "$push", "$pull", "$addToSet", "$rename", "$currentDate", "$setOnInsert"];
|
||||
const PIPELINE_STAGES = ["$match", "$project", "$group", "$sort", "$limit", "$skip", "$unwind", "$lookup", "$addFields", "$count", "$facet"];
|
||||
const QUERY_METHODS = ["find", "findOne", "countDocuments", "updateOne", "updateMany", "deleteOne", "deleteMany", "sort"];
|
||||
|
||||
export function getMongoCompletionContext(text: string, cursor: number): MongoCompletionContext {
|
||||
const safeCursor = Math.max(0, Math.min(cursor, text.length));
|
||||
const operatorPrefix = readOperatorPrefix(text, safeCursor);
|
||||
const collection = extractActiveCollection(text, safeCursor);
|
||||
|
||||
if (operatorPrefix) {
|
||||
return {
|
||||
mode: isLikelyAggregationPipeline(text, safeCursor) ? "stage" : "operator",
|
||||
prefix: operatorPrefix.prefix,
|
||||
from: operatorPrefix.from,
|
||||
collection,
|
||||
};
|
||||
}
|
||||
|
||||
const propertyPrefix = readPropertyPrefix(text, safeCursor);
|
||||
const beforeCursor = text.slice(0, safeCursor);
|
||||
|
||||
if (isInsideOperatorValueObject(beforeCursor)) {
|
||||
return { mode: "operator", prefix: propertyPrefix.prefix, from: propertyPrefix.from, collection };
|
||||
}
|
||||
|
||||
if (/db\.$/.test(beforeCursor)) {
|
||||
return { mode: "collection", prefix: "", from: safeCursor, collection };
|
||||
}
|
||||
|
||||
const collectionPrefix = matchDbCollectionPrefix(beforeCursor);
|
||||
if (collectionPrefix) {
|
||||
return { mode: "collection", prefix: collectionPrefix.prefix, from: collectionPrefix.from, collection };
|
||||
}
|
||||
|
||||
if (isAfterCollectionDot(beforeCursor)) {
|
||||
const methodPrefix = readMethodPrefix(beforeCursor);
|
||||
return { mode: "method", prefix: methodPrefix.prefix, from: methodPrefix.from, collection };
|
||||
}
|
||||
|
||||
if (isAfterCursorMethodDot(beforeCursor)) {
|
||||
const methodPrefix = readMethodPrefix(beforeCursor);
|
||||
return { mode: "cursorMethod", prefix: methodPrefix.prefix, from: methodPrefix.from, collection };
|
||||
}
|
||||
|
||||
if (isLikelyFieldPosition(text, safeCursor)) {
|
||||
return { mode: "field", prefix: propertyPrefix.prefix, from: propertyPrefix.from, collection };
|
||||
}
|
||||
|
||||
return { mode: "root", prefix: propertyPrefix.prefix, from: propertyPrefix.from, collection };
|
||||
}
|
||||
|
||||
export function buildMongoCompletionItems(text: string, cursor: number, input: MongoCompletionInput = {}): MongoCompletionItem[] {
|
||||
return buildMongoCompletionItemsFromContext(getMongoCompletionContext(text, cursor), input);
|
||||
}
|
||||
|
||||
export function buildMongoCompletionItemsFromContext(context: MongoCompletionContext, input: MongoCompletionInput = {}): MongoCompletionItem[] {
|
||||
if (context.mode === "collection") return collectionItems(context.prefix, input.collections ?? []);
|
||||
if (context.mode === "method") return methodItems(context.prefix);
|
||||
if (context.mode === "cursorMethod") return cursorMethodItems(context.prefix);
|
||||
if (context.mode === "field") return fieldItems(context.prefix, input.fields ?? []);
|
||||
if (context.mode === "operator") return operatorItems(context.prefix);
|
||||
if (context.mode === "stage") return stageItems(context.prefix);
|
||||
return rootItems(context.prefix);
|
||||
}
|
||||
|
||||
export function shouldAutoOpenMongoCompletion(text: string, cursor: number): boolean {
|
||||
const previousChar = text[cursor - 1];
|
||||
if (!previousChar) return false;
|
||||
if (/db\.$/.test(text.slice(0, cursor))) return true;
|
||||
if (previousChar === "$" || previousChar === "." || previousChar === '"') return true;
|
||||
if (/[{,[]/.test(previousChar)) return isLikelyFieldPosition(text, cursor) || isLikelyAggregationPipeline(text, cursor);
|
||||
if (/[\w_$.-]/.test(previousChar)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function getMongoCompletionResultValidFor(): RegExp {
|
||||
return /[\w_$.-]*$/;
|
||||
}
|
||||
|
||||
export function inferMongoCompletionFields(documents: unknown[]): MongoCompletionField[] {
|
||||
const typeByPath = new Map<string, Set<string>>();
|
||||
for (const doc of documents) collectFieldTypes(doc, "", typeByPath, 0);
|
||||
return [...typeByPath.entries()]
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([name, types]) => ({ name, type: [...types].sort().join(" | ") }));
|
||||
}
|
||||
|
||||
function rootItems(prefix: string): MongoCompletionItem[] {
|
||||
const snippets = ROOT_SNIPPETS.filter((snippet) => matchesFuzzyPrefix(snippet.label, prefix)).map((snippet) => ({
|
||||
label: snippet.label,
|
||||
type: "snippet" as const,
|
||||
detail: snippet.detail,
|
||||
apply: snippet.apply,
|
||||
boost: 120,
|
||||
}));
|
||||
const methods = [...COLLECTION_METHODS, ...CURSOR_METHODS]
|
||||
.filter((method) => matchesFuzzyPrefix(method.label, prefix))
|
||||
.map((method) => ({
|
||||
label: method.label,
|
||||
type: "function" as const,
|
||||
detail: method.detail,
|
||||
apply: method.apply,
|
||||
boost: 100,
|
||||
}));
|
||||
return dedupeAndSort([...snippets, ...methods]);
|
||||
}
|
||||
|
||||
function collectionItems(prefix: string, collections: string[]): MongoCompletionItem[] {
|
||||
return collections
|
||||
.filter((collection) => matchesFuzzyPrefix(collection, prefix))
|
||||
.slice(0, 100)
|
||||
.map((collection) => ({
|
||||
label: collection,
|
||||
type: "table" as const,
|
||||
detail: "collection",
|
||||
apply: needsGetCollectionSyntax(collection) ? `getCollection("${escapeDoubleQuoted(collection)}")` : collection,
|
||||
boost: collection.toLowerCase().startsWith(prefix.toLowerCase()) ? 120 : 90,
|
||||
}));
|
||||
}
|
||||
|
||||
function methodItems(prefix: string): MongoCompletionItem[] {
|
||||
return dedupeAndSort(
|
||||
[...COLLECTION_METHODS, ...CURSOR_METHODS]
|
||||
.filter((method) => matchesFuzzyPrefix(method.label, prefix))
|
||||
.map((method) => ({
|
||||
label: method.label,
|
||||
type: "function" as const,
|
||||
detail: method.detail,
|
||||
apply: method.apply,
|
||||
boost: method.label === "find" || method.label === "aggregate" ? 130 : 100,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
function cursorMethodItems(prefix: string): MongoCompletionItem[] {
|
||||
return dedupeAndSort(
|
||||
CURSOR_METHODS.filter((method) => matchesFuzzyPrefix(method.label, prefix)).map((method) => ({
|
||||
label: method.label,
|
||||
type: "function" as const,
|
||||
detail: method.detail,
|
||||
apply: method.apply,
|
||||
boost: method.label === "limit" ? 130 : 110,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
function fieldItems(prefix: string, fields: MongoCompletionField[]): MongoCompletionItem[] {
|
||||
const normalizedPrefix = normalizeMongoKeyPrefix(prefix);
|
||||
const observedFields = fields
|
||||
.filter((field) => matchesFuzzyPrefix(field.name, normalizedPrefix))
|
||||
.slice(0, 100)
|
||||
.map((field) => ({
|
||||
label: field.name,
|
||||
type: "column" as const,
|
||||
detail: field.type ? `observed field · ${field.type}` : "observed field",
|
||||
apply: mongoFieldApplyText(field.name, prefix),
|
||||
boost: field.name.toLowerCase().startsWith(normalizedPrefix.toLowerCase()) ? 120 : 85,
|
||||
}));
|
||||
const snippets = FIELD_SNIPPETS.filter((snippet) => matchesFuzzyPrefix(snippet.label, normalizedPrefix)).map((snippet) => ({
|
||||
label: snippet.label,
|
||||
type: "snippet" as const,
|
||||
detail: snippet.detail,
|
||||
apply: snippet.apply,
|
||||
boost: 95,
|
||||
}));
|
||||
return dedupeAndSort([...observedFields, ...snippets]);
|
||||
}
|
||||
|
||||
function operatorItems(prefix: string): MongoCompletionItem[] {
|
||||
return [...QUERY_OPERATORS, ...UPDATE_OPERATORS]
|
||||
.filter((operator) => matchesFuzzyPrefix(operator, prefix))
|
||||
.map((operator) => ({
|
||||
label: operator,
|
||||
type: "keyword" as const,
|
||||
detail: operator.startsWith("$set") || UPDATE_OPERATORS.includes(operator) ? "update operator" : "query operator",
|
||||
apply: operator,
|
||||
boost: operator === "$set" || operator === "$match" ? 115 : 90,
|
||||
}));
|
||||
}
|
||||
|
||||
function stageItems(prefix: string): MongoCompletionItem[] {
|
||||
const stages = PIPELINE_STAGES.filter((stage) => matchesFuzzyPrefix(stage, prefix)).map((stage) => ({
|
||||
label: stage,
|
||||
type: "keyword" as const,
|
||||
detail: "aggregation stage",
|
||||
apply: stage,
|
||||
boost: stage === "$match" || stage === "$project" ? 120 : 95,
|
||||
}));
|
||||
const snippets = [
|
||||
{ label: "$match stage", apply: "$match: {}", detail: "Filter documents in a pipeline" },
|
||||
{ label: "$group stage", apply: '$group: { _id: "$${field}", count: { $sum: 1 } }', detail: "Group documents" },
|
||||
{ label: "$lookup stage", apply: '$lookup: { from: "${collection}", localField: "${field}", foreignField: "_id", as: "${as}" }', detail: "Join another collection" },
|
||||
]
|
||||
.filter((snippet) => matchesFuzzyPrefix(snippet.label, prefix))
|
||||
.map((snippet) => ({ ...snippet, type: "snippet" as const, boost: 110 }));
|
||||
return dedupeAndSort([...snippets, ...stages]);
|
||||
}
|
||||
|
||||
function readPropertyPrefix(text: string, cursor: number): { prefix: string; from: number } {
|
||||
let from = cursor;
|
||||
while (from > 0 && /[\w_$.-]/.test(text[from - 1] ?? "")) from--;
|
||||
if (text[from - 1] === '"' || text[from - 1] === "'") from--;
|
||||
return { prefix: text.slice(from, cursor), from };
|
||||
}
|
||||
|
||||
function readOperatorPrefix(text: string, cursor: number): { prefix: string; from: number } | null {
|
||||
let from = cursor;
|
||||
while (from > 0 && /[\w$]/.test(text[from - 1] ?? "")) from--;
|
||||
const prefix = text.slice(from, cursor);
|
||||
return prefix.startsWith("$") ? { prefix, from } : null;
|
||||
}
|
||||
|
||||
function readMethodPrefix(beforeCursor: string): { prefix: string; from: number } {
|
||||
const dot = beforeCursor.lastIndexOf(".");
|
||||
const from = dot >= 0 ? dot + 1 : beforeCursor.length;
|
||||
return { prefix: beforeCursor.slice(from), from };
|
||||
}
|
||||
|
||||
function matchDbCollectionPrefix(beforeCursor: string): { prefix: string; from: number } | null {
|
||||
const match = /(?:^|[\s;(])db\.([A-Za-z_][\w$-]*)$/.exec(beforeCursor);
|
||||
if (!match) return null;
|
||||
const prefix = match[1] ?? "";
|
||||
return { prefix, from: beforeCursor.length - prefix.length };
|
||||
}
|
||||
|
||||
function isAfterCollectionDot(beforeCursor: string): boolean {
|
||||
return /(?:^|[\s;(])db\.(?:[A-Za-z_][\w$-]*|getCollection\(["'][^"']+["']\))\.[\w$-]*$/.test(beforeCursor);
|
||||
}
|
||||
|
||||
function isAfterCursorMethodDot(beforeCursor: string): boolean {
|
||||
return /(?:^|[\s;(])db\.(?:[A-Za-z_][\w$-]*|getCollection\(["'][^"']+["']\))\.(?:find|aggregate)\s*\([\s\S]*\)(?:\s*\.\s*(?:sort|skip|limit)\s*\([\s\S]*\))*\s*\.\s*[\w$-]*$/.test(beforeCursor);
|
||||
}
|
||||
|
||||
function isLikelyFieldPosition(text: string, cursor: number): boolean {
|
||||
const before = text.slice(0, cursor);
|
||||
if (!/[{,]\s*["']?[\w$.-]*$/.test(before)) return false;
|
||||
const call = findInnermostMongoCall(before);
|
||||
if (!call) return false;
|
||||
if (call.method === "aggregate") return isLikelyAggregationPipeline(text, cursor);
|
||||
if (!QUERY_METHODS.includes(call.method)) return false;
|
||||
if (isInsideOperatorValueObject(before)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function isLikelyAggregationPipeline(text: string, cursor: number): boolean {
|
||||
const before = text.slice(0, cursor);
|
||||
const aggregatePos = before.lastIndexOf(".aggregate");
|
||||
if (aggregatePos < 0) return false;
|
||||
const afterAggregate = before.slice(aggregatePos);
|
||||
return afterAggregate.lastIndexOf("[") > afterAggregate.lastIndexOf("]");
|
||||
}
|
||||
|
||||
function extractActiveCollection(text: string, cursor: number): string | undefined {
|
||||
const before = text.slice(0, cursor);
|
||||
const getCollectionMatches = [...before.matchAll(/db\.getCollection\(["']([^"']+)["']\)/g)];
|
||||
const directMatches = [...before.matchAll(/db\.([A-Za-z_][\w$-]*)\s*\./g)].filter((match) => match[1] !== "getCollection");
|
||||
const lastGetCollection = getCollectionMatches[getCollectionMatches.length - 1];
|
||||
const lastDirect = directMatches[directMatches.length - 1];
|
||||
const getCollectionIndex = lastGetCollection?.index ?? -1;
|
||||
const directIndex = lastDirect?.index ?? -1;
|
||||
if (getCollectionIndex > directIndex) return lastGetCollection?.[1];
|
||||
return lastDirect?.[1];
|
||||
}
|
||||
|
||||
function collectFieldTypes(value: unknown, prefix: string, out: Map<string, Set<string>>, depth: number) {
|
||||
if (depth > 4 || value == null || typeof value !== "object") return;
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value.slice(0, 3)) collectFieldTypes(item, prefix, out, depth + 1);
|
||||
return;
|
||||
}
|
||||
for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
|
||||
const path = prefix ? `${prefix}.${key}` : key;
|
||||
if (!out.has(path)) out.set(path, new Set());
|
||||
out.get(path)?.add(describeMongoValueType(child));
|
||||
collectFieldTypes(child, path, out, depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
function describeMongoValueType(value: unknown): string {
|
||||
if (value == null) return "null";
|
||||
if (Array.isArray(value)) return "array";
|
||||
if (value instanceof Date) return "date";
|
||||
return typeof value === "object" ? "object" : typeof value;
|
||||
}
|
||||
|
||||
function quoteMongoFieldName(field: string, prefix: string): string {
|
||||
if (prefix.startsWith('"')) return `"${escapeDoubleQuoted(field)}"`;
|
||||
if (prefix.startsWith("'")) return `'${field.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`;
|
||||
return field;
|
||||
}
|
||||
|
||||
function mongoFieldApplyText(field: string, prefix: string): string {
|
||||
const quoted = quoteMongoFieldName(field, prefix);
|
||||
return `${quoted}: `;
|
||||
}
|
||||
|
||||
function findInnermostMongoCall(beforeCursor: string): { method: string; openParenIndex: number } | null {
|
||||
const callPattern = /\.(find|findOne|countDocuments|updateOne|updateMany|deleteOne|deleteMany|aggregate|sort)\s*\(/g;
|
||||
let match: RegExpExecArray | null;
|
||||
let result: { method: string; openParenIndex: number } | null = null;
|
||||
while ((match = callPattern.exec(beforeCursor))) {
|
||||
const method = match[1];
|
||||
const openParenIndex = match.index + match[0].lastIndexOf("(");
|
||||
if (method) result = { method, openParenIndex };
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function isInsideOperatorValueObject(beforeCursor: string): boolean {
|
||||
const call = findInnermostMongoCall(beforeCursor);
|
||||
if (!call) return false;
|
||||
const callArgumentsBeforeCursor = beforeCursor.slice(call.openParenIndex + 1);
|
||||
return /(?:^|[{,])\s*["']?[\w$.-]+["']?\s*:\s*\{\s*["']?[\w$.-]*$/.test(callArgumentsBeforeCursor);
|
||||
}
|
||||
|
||||
function normalizeMongoKeyPrefix(prefix: string): string {
|
||||
return prefix.replace(/^["']/, "");
|
||||
}
|
||||
|
||||
function needsGetCollectionSyntax(collection: string): boolean {
|
||||
return !/^[A-Za-z_][\w$]*$/.test(collection);
|
||||
}
|
||||
|
||||
function escapeDoubleQuoted(value: string): string {
|
||||
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
||||
}
|
||||
|
||||
function matchesFuzzyPrefix(value: string, prefix: string): boolean {
|
||||
const normalizedValue = value.toLowerCase();
|
||||
const normalizedPrefix = prefix.toLowerCase().replace(/^["']/, "");
|
||||
if (!normalizedPrefix) return true;
|
||||
return normalizedValue.startsWith(normalizedPrefix) || normalizedValue.includes(normalizedPrefix);
|
||||
}
|
||||
|
||||
function dedupeAndSort(items: MongoCompletionItem[]): MongoCompletionItem[] {
|
||||
const seen = new Set<string>();
|
||||
const deduped: MongoCompletionItem[] = [];
|
||||
for (const item of items) {
|
||||
const key = `${item.type}:${item.label}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
deduped.push(item);
|
||||
}
|
||||
return deduped.sort((a, b) => b.boost - a.boost || a.label.localeCompare(b.label));
|
||||
}
|
||||
|
|
@ -0,0 +1,277 @@
|
|||
import { DEFAULT_SQL_FORMATTER_SETTINGS, type SqlFormatterSettings } from "@/lib/sqlFormatterConfig";
|
||||
|
||||
export const MAX_MONGO_FORMAT_CHARS = 1_000_000;
|
||||
|
||||
interface FormatState {
|
||||
out: string;
|
||||
indentLevel: number;
|
||||
atLineStart: boolean;
|
||||
pendingSpace: boolean;
|
||||
chainIndent: boolean;
|
||||
pendingChainCall: boolean;
|
||||
stack: Array<{ char: string; expanded: boolean; chainCall?: boolean }>;
|
||||
}
|
||||
|
||||
const CHAIN_METHODS = new Set(["find", "findOne", "aggregate", "countDocuments", "distinct", "insertOne", "insertMany", "updateOne", "updateMany", "deleteOne", "deleteMany", "getIndexes", "createIndex", "sort", "limit", "skip"]);
|
||||
|
||||
export function formatMongoShellText(text: string, settings: Partial<SqlFormatterSettings> = DEFAULT_SQL_FORMATTER_SETTINGS): string {
|
||||
if (!text.trim()) return text;
|
||||
if (text.length > MAX_MONGO_FORMAT_CHARS) {
|
||||
throw new Error("MongoDB query is too large to format safely.");
|
||||
}
|
||||
|
||||
const indentUnit = settings.useTabs ? "\t" : " ".repeat(settings.tabWidth ?? DEFAULT_SQL_FORMATTER_SETTINGS.tabWidth);
|
||||
const state: FormatState = { out: "", indentLevel: 0, atLineStart: true, pendingSpace: false, chainIndent: false, pendingChainCall: false, stack: [] };
|
||||
|
||||
for (let index = 0; index < text.length; index++) {
|
||||
const char = text[index] ?? "";
|
||||
const next = text[index + 1] ?? "";
|
||||
|
||||
if (char === '"' || char === "'" || char === "`") {
|
||||
const literal = readQuotedLiteral(text, index, char);
|
||||
appendToken(state, literal.value, indentUnit);
|
||||
index = literal.end;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "/" && next === "/") {
|
||||
const comment = readLineComment(text, index);
|
||||
appendToken(state, comment.value, indentUnit);
|
||||
index = comment.end;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "/" && next === "*") {
|
||||
const comment = readBlockComment(text, index);
|
||||
appendToken(state, comment.value, indentUnit);
|
||||
index = comment.end;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "/" && looksLikeRegexLiteral(text, index)) {
|
||||
const regex = readRegexLiteral(text, index);
|
||||
appendToken(state, regex.value, indentUnit);
|
||||
index = regex.end;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/\s/.test(char)) {
|
||||
state.pendingSpace = !state.atLineStart;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "." && shouldBreakBeforeDot(text, index)) {
|
||||
newline(state, indentUnit);
|
||||
state.chainIndent = true;
|
||||
state.pendingChainCall = true;
|
||||
appendToken(state, ".", indentUnit);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "{" || char === "[" || char === "(") {
|
||||
appendToken(state, char, indentUnit);
|
||||
const expanded = char !== "(" && shouldExpandOpening(text, index);
|
||||
const isChainCall = char === "(" && state.pendingChainCall;
|
||||
state.stack.push({ char, expanded, chainCall: isChainCall });
|
||||
if (isChainCall) state.indentLevel++;
|
||||
state.pendingChainCall = false;
|
||||
if (expanded) {
|
||||
state.indentLevel++;
|
||||
newline(state, indentUnit);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "}" || char === "]" || char === ")") {
|
||||
const frame = popMatchingFrame(state, char);
|
||||
if (frame?.expanded) {
|
||||
if (!state.atLineStart) newline(state, indentUnit, -1);
|
||||
else state.indentLevel = Math.max(0, state.indentLevel - 1);
|
||||
}
|
||||
if (frame?.chainCall) {
|
||||
state.indentLevel = Math.max(0, state.indentLevel - 1);
|
||||
}
|
||||
appendRaw(state, char, indentUnit);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === ",") {
|
||||
appendToken(state, ",", indentUnit);
|
||||
newline(state, indentUnit);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === ":") {
|
||||
trimTrailingSpaces(state);
|
||||
appendRaw(state, ": ", indentUnit);
|
||||
state.pendingSpace = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
appendToken(state, char, indentUnit);
|
||||
}
|
||||
|
||||
return cleanupFormattedMongoText(state.out);
|
||||
}
|
||||
|
||||
function appendToken(state: FormatState, token: string, indentUnit: string) {
|
||||
if (state.atLineStart) {
|
||||
state.out += indentUnit.repeat(Math.max(0, state.indentLevel + (state.chainIndent ? 1 : 0)));
|
||||
state.atLineStart = false;
|
||||
} else if (state.pendingSpace && shouldInsertPendingSpace(state.out, token)) {
|
||||
state.out += " ";
|
||||
}
|
||||
state.out += token;
|
||||
state.pendingSpace = false;
|
||||
state.chainIndent = false;
|
||||
}
|
||||
|
||||
function appendRaw(state: FormatState, token: string, indentUnit: string) {
|
||||
if (state.atLineStart) {
|
||||
state.out += indentUnit.repeat(Math.max(0, state.indentLevel + (state.chainIndent ? 1 : 0)));
|
||||
state.atLineStart = false;
|
||||
}
|
||||
state.out += token;
|
||||
state.pendingSpace = false;
|
||||
state.chainIndent = false;
|
||||
}
|
||||
|
||||
function newline(state: FormatState, indentUnit: string, indentDelta = 0) {
|
||||
trimTrailingSpaces(state);
|
||||
if (!state.out.endsWith("\n")) state.out += "\n";
|
||||
state.indentLevel = Math.max(0, state.indentLevel + indentDelta);
|
||||
state.atLineStart = true;
|
||||
state.pendingSpace = false;
|
||||
state.chainIndent = false;
|
||||
void indentUnit;
|
||||
}
|
||||
|
||||
function shouldInsertPendingSpace(output: string, token: string): boolean {
|
||||
const previous = lastNonWhitespace(output);
|
||||
if (!previous) return false;
|
||||
if ([".", "(", "[", "{"].includes(previous)) return false;
|
||||
if ([".", ")", "]", "}", ",", ":"].includes(token)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function shouldExpandOpening(text: string, index: number): boolean {
|
||||
const close = matchingClose(text[index] ?? "");
|
||||
const nextNonSpace = findNextNonWhitespace(text, index + 1);
|
||||
if (nextNonSpace == null || text[nextNonSpace] === close) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function popMatchingFrame(state: FormatState, close: string): { char: string; expanded: boolean; chainCall?: boolean } | undefined {
|
||||
const expectedOpen = close === "}" ? "{" : close === "]" ? "[" : "(";
|
||||
for (let index = state.stack.length - 1; index >= 0; index--) {
|
||||
const frame = state.stack[index];
|
||||
state.stack.splice(index, state.stack.length - index);
|
||||
if (frame?.char === expectedOpen) return frame;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function shouldBreakBeforeDot(text: string, index: number): boolean {
|
||||
const method = readIdentifier(text, findNextNonWhitespace(text, index + 1) ?? index + 1);
|
||||
if (!method || !CHAIN_METHODS.has(method)) return false;
|
||||
const previousNonSpace = findPreviousNonWhitespace(text, index - 1);
|
||||
if (previousNonSpace == null) return false;
|
||||
return text[previousNonSpace] === ")";
|
||||
}
|
||||
|
||||
function readQuotedLiteral(text: string, start: number, quote: string): { value: string; end: number } {
|
||||
let index = start + 1;
|
||||
while (index < text.length) {
|
||||
const char = text[index] ?? "";
|
||||
if (char === "\\") {
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
if (char === quote) return { value: text.slice(start, index + 1), end: index };
|
||||
index++;
|
||||
}
|
||||
return { value: text.slice(start), end: text.length - 1 };
|
||||
}
|
||||
|
||||
function readLineComment(text: string, start: number): { value: string; end: number } {
|
||||
const end = text.indexOf("\n", start);
|
||||
if (end < 0) return { value: text.slice(start), end: text.length - 1 };
|
||||
return { value: text.slice(start, end), end: end - 1 };
|
||||
}
|
||||
|
||||
function readBlockComment(text: string, start: number): { value: string; end: number } {
|
||||
const end = text.indexOf("*/", start + 2);
|
||||
if (end < 0) return { value: text.slice(start), end: text.length - 1 };
|
||||
return { value: text.slice(start, end + 2), end: end + 1 };
|
||||
}
|
||||
|
||||
function readRegexLiteral(text: string, start: number): { value: string; end: number } {
|
||||
let index = start + 1;
|
||||
let inCharClass = false;
|
||||
while (index < text.length) {
|
||||
const char = text[index] ?? "";
|
||||
if (char === "\\") {
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
if (char === "[") inCharClass = true;
|
||||
else if (char === "]") inCharClass = false;
|
||||
else if (char === "/" && !inCharClass) {
|
||||
let end = index;
|
||||
while (/[a-z]/i.test(text[end + 1] ?? "")) end++;
|
||||
return { value: text.slice(start, end + 1), end };
|
||||
}
|
||||
index++;
|
||||
}
|
||||
return { value: text.slice(start), end: text.length - 1 };
|
||||
}
|
||||
|
||||
function looksLikeRegexLiteral(text: string, index: number): boolean {
|
||||
const previous = findPreviousNonWhitespace(text, index - 1);
|
||||
if (previous == null) return true;
|
||||
return "({[,=:!&|?".includes(text[previous] ?? "");
|
||||
}
|
||||
|
||||
function readIdentifier(text: string, start: number): string {
|
||||
let index = start;
|
||||
while (index < text.length && /[A-Za-z0-9_$]/.test(text[index] ?? "")) index++;
|
||||
return text.slice(start, index);
|
||||
}
|
||||
|
||||
function matchingClose(open: string): string {
|
||||
if (open === "{") return "}";
|
||||
if (open === "[") return "]";
|
||||
return ")";
|
||||
}
|
||||
|
||||
function findNextNonWhitespace(text: string, start: number): number | null {
|
||||
for (let index = start; index < text.length; index++) {
|
||||
if (!/\s/.test(text[index] ?? "")) return index;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findPreviousNonWhitespace(text: string, start: number): number | null {
|
||||
for (let index = start; index >= 0; index--) {
|
||||
if (!/\s/.test(text[index] ?? "")) return index;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function lastNonWhitespace(text: string): string | null {
|
||||
const index = findPreviousNonWhitespace(text, text.length - 1);
|
||||
return index == null ? null : (text[index] ?? null);
|
||||
}
|
||||
|
||||
function trimTrailingSpaces(state: FormatState) {
|
||||
state.out = state.out.replace(/[ \t]+$/g, "");
|
||||
}
|
||||
|
||||
function cleanupFormattedMongoText(text: string): string {
|
||||
return text
|
||||
.split("\n")
|
||||
.map((line) => line.replace(/[ \t]+$/g, ""))
|
||||
.join("\n")
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.trim();
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import type { DatabaseType, SqlSnippet } from "@/types/database";
|
||||
import { buildMongoCompletionItemsFromContext, type MongoCompletionItem } from "@/lib/mongoCompletion";
|
||||
|
||||
const SQL_KEYWORDS = [
|
||||
"SELECT",
|
||||
|
|
@ -1164,7 +1165,7 @@ class SqlCompletionProvider {
|
|||
const { context } = this;
|
||||
|
||||
if (this.databaseType === "mongodb") {
|
||||
return dedupeAndSort(buildMongoCompletionItems(context.prefix));
|
||||
return dedupeAndSort(buildMongoCompletionItemsFromContext({ mode: "root", prefix: context.prefix, from: 0 }).map(mongoCompletionItemToSqlCompletionItem));
|
||||
}
|
||||
|
||||
if (!context.exclusiveTableSuggestions && !context.exclusiveColumnSuggestions && !context.exclusiveRoutineSuggestions) {
|
||||
|
|
@ -3131,37 +3132,15 @@ function buildFunctionSnippetItems(prefix: string, functionDescriptions: Map<str
|
|||
return items;
|
||||
}
|
||||
|
||||
const MONGO_COMPLETIONS: Array<Pick<SqlCompletionItem, "label" | "type" | "detail" | "apply">> = [
|
||||
{ label: "find", type: "function", detail: "MongoDB query documents", apply: "find({})" },
|
||||
{ label: "findOne", type: "function", detail: "MongoDB query one document", apply: "findOne({})" },
|
||||
{ label: "aggregate", type: "function", detail: "MongoDB aggregation pipeline", apply: "aggregate([])" },
|
||||
{
|
||||
label: "countDocuments",
|
||||
type: "function",
|
||||
detail: "MongoDB count matching documents",
|
||||
apply: "countDocuments({})",
|
||||
},
|
||||
{ label: "distinct", type: "function", detail: "MongoDB distinct field values", apply: 'distinct("field", {})' },
|
||||
{ label: "insertOne", type: "function", detail: "MongoDB insert one document", apply: "insertOne({})" },
|
||||
{ label: "updateOne", type: "function", detail: "MongoDB update one document", apply: "updateOne({}, { $set: {} })" },
|
||||
{ label: "deleteOne", type: "function", detail: "MongoDB delete one document", apply: "deleteOne({})" },
|
||||
{ label: "sort", type: "function", detail: "MongoDB sort cursor", apply: "sort({ field: 1 })" },
|
||||
{ label: "limit", type: "function", detail: "MongoDB limit cursor", apply: "limit(100)" },
|
||||
{ label: "skip", type: "function", detail: "MongoDB skip cursor", apply: "skip(0)" },
|
||||
{ label: "db.collection.find", type: "snippet", detail: "MongoDB find command", apply: "db.collection.find({})" },
|
||||
{
|
||||
label: "db.collection.aggregate",
|
||||
type: "snippet",
|
||||
detail: "MongoDB aggregate command",
|
||||
apply: "db.collection.aggregate([])",
|
||||
},
|
||||
];
|
||||
|
||||
function buildMongoCompletionItems(prefix: string): SqlCompletionItem[] {
|
||||
return MONGO_COMPLETIONS.filter((item) => matchesPrefix(item.label, prefix)).map((item) => ({
|
||||
...item,
|
||||
boost: computeBoost(item.label, prefix) + (item.type === "snippet" ? 400 : 600),
|
||||
}));
|
||||
function mongoCompletionItemToSqlCompletionItem(item: MongoCompletionItem): SqlCompletionItem {
|
||||
return {
|
||||
label: item.label,
|
||||
type: item.type,
|
||||
detail: item.detail,
|
||||
info: item.info,
|
||||
apply: item.apply,
|
||||
boost: item.boost,
|
||||
};
|
||||
}
|
||||
|
||||
function buildSelectAliasItems(context: SqlCompletionContext): SqlCompletionItem[] {
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ import { supportsDatabaseUserAdmin } from "@/lib/databaseUserAdmin";
|
|||
import { getTableMetadataCapabilities } from "@/lib/tableMetadataCapabilities";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import { encodeSqlServerLinkedSchema, parseSqlServerLinkedSchema } from "@/lib/sqlServerLinkedServers";
|
||||
import { inferMongoCompletionFields, type MongoCompletionField } from "@/lib/mongoCompletion";
|
||||
|
||||
const PINNED_TREE_NODES_STORAGE_KEY = "dbx-pinned-tree-nodes";
|
||||
const ACTIVE_CONNECTION_STORAGE_KEY = "dbx-active-connection";
|
||||
|
|
@ -155,6 +156,8 @@ export const useConnectionStore = defineStore("connection", () => {
|
|||
const completionDatabasesCache = ref<Record<string, string[]>>({});
|
||||
const elasticsearchCompletionIndicesCache = ref<Record<string, string[]>>({});
|
||||
const redisCompletionKeysCache = ref<Record<string, string[]>>({});
|
||||
const mongoCompletionCollectionsCache = ref<Record<string, string[]>>({});
|
||||
const mongoCompletionFieldsCache = ref<Record<string, MongoCompletionField[]>>({});
|
||||
const schemaListCache = ref<Record<string, string[]>>({});
|
||||
const sidebarSearchQuery = ref("");
|
||||
const completionTableIndex = new Map<string, { touched: number; tables: SqlCompletionTable[] }>();
|
||||
|
|
@ -658,6 +661,12 @@ export const useConnectionStore = defineStore("connection", () => {
|
|||
for (const key of Object.keys(redisCompletionKeysCache.value)) {
|
||||
if (key === exactCacheKey || key.startsWith(cachePrefix)) delete redisCompletionKeysCache.value[key];
|
||||
}
|
||||
for (const key of Object.keys(mongoCompletionCollectionsCache.value)) {
|
||||
if (key === exactCacheKey || key.startsWith(cachePrefix)) delete mongoCompletionCollectionsCache.value[key];
|
||||
}
|
||||
for (const key of Object.keys(mongoCompletionFieldsCache.value)) {
|
||||
if (key === exactCacheKey || key.startsWith(cachePrefix)) delete mongoCompletionFieldsCache.value[key];
|
||||
}
|
||||
for (const key of completionTableIndex.keys()) {
|
||||
if (key.startsWith(cachePrefix)) completionTableIndex.delete(key);
|
||||
}
|
||||
|
|
@ -2239,6 +2248,35 @@ export const useConnectionStore = defineStore("connection", () => {
|
|||
});
|
||||
}
|
||||
|
||||
async function listMongoCompletionCollections(connectionId: string, database: string): Promise<string[]> {
|
||||
if (!database) return [];
|
||||
const cacheKey = `${connectionId}:${database}`;
|
||||
const cached = mongoCompletionCollectionsCache.value[cacheKey];
|
||||
if (cached) return cached;
|
||||
return withCompletionInFlight(`${cacheKey}:mongo-collections`, async () => {
|
||||
await ensureConnected(connectionId);
|
||||
const collections = sortSidebarNames(await api.mongoListCollections(connectionId, database));
|
||||
mongoCompletionCollectionsCache.value[cacheKey] = collections;
|
||||
evictOldestCacheEntries(mongoCompletionCollectionsCache.value, COMPLETION_CACHE_MAX);
|
||||
return collections;
|
||||
});
|
||||
}
|
||||
|
||||
async function listMongoCompletionFields(connectionId: string, database: string, collection: string): Promise<MongoCompletionField[]> {
|
||||
if (!database || !collection) return [];
|
||||
const cacheKey = `${connectionId}:${database}:${collection}`;
|
||||
const cached = mongoCompletionFieldsCache.value[cacheKey];
|
||||
if (cached) return cached;
|
||||
return withCompletionInFlight(`${cacheKey}:mongo-fields`, async () => {
|
||||
await ensureConnected(connectionId);
|
||||
const result = await api.mongoFindDocuments(connectionId, database, collection, 0, 20, "{}");
|
||||
const fields = inferMongoCompletionFields(result.documents ?? []);
|
||||
mongoCompletionFieldsCache.value[cacheKey] = fields;
|
||||
evictOldestCacheEntries(mongoCompletionFieldsCache.value, COMPLETION_CACHE_MAX);
|
||||
return fields;
|
||||
});
|
||||
}
|
||||
|
||||
async function listCompletionTables(connectionId: string, database: string, filter = "", limit?: number, schema?: string): Promise<SqlCompletionTable[]> {
|
||||
const normalizedFilter = filter.trim().toLowerCase();
|
||||
const relaxedFilter = relaxedCompletionTableFilter(normalizedFilter);
|
||||
|
|
@ -2534,7 +2572,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
|||
function persistSidebarLayoutDebounced() {
|
||||
if (layoutPersistTimer) clearTimeout(layoutPersistTimer);
|
||||
layoutPersistTimer = setTimeout(() => {
|
||||
api.saveSidebarLayout(sidebarLayout.value).catch(() => {});
|
||||
api.saveSidebarLayout(sidebarLayout.value).catch(() => { });
|
||||
layoutPersistTimer = null;
|
||||
}, 300);
|
||||
}
|
||||
|
|
@ -3040,6 +3078,8 @@ export const useConnectionStore = defineStore("connection", () => {
|
|||
refreshCompletionDatabases,
|
||||
listElasticsearchCompletionIndices,
|
||||
listRedisCompletionKeys,
|
||||
listMongoCompletionCollections,
|
||||
listMongoCompletionFields,
|
||||
invalidateCompletionCache,
|
||||
exportConnectionsToFile,
|
||||
readImportFile,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,125 @@
|
|||
import { strict as assert } from "node:assert";
|
||||
import { test } from "vitest";
|
||||
import { buildMongoCompletionItems, getMongoCompletionContext, inferMongoCompletionFields, shouldAutoOpenMongoCompletion } from "../../apps/desktop/src/lib/mongoCompletion.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" },
|
||||
];
|
||||
|
||||
function labels(text: string, input = {}) {
|
||||
return buildMongoCompletionItems(text, text.length, input).map((item) => item.label);
|
||||
}
|
||||
|
||||
test("suggests MongoDB root snippets and methods", () => {
|
||||
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);
|
||||
});
|
||||
|
||||
test("suggests collections after db dot", () => {
|
||||
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"],
|
||||
);
|
||||
});
|
||||
|
||||
test("uses getCollection apply text for unsafe collection names", () => {
|
||||
const item = buildMongoCompletionItems("db.order", "db.order".length, { collections }).find((candidate) => candidate.label === "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"));
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
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"],
|
||||
);
|
||||
});
|
||||
|
||||
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");
|
||||
|
||||
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 });
|
||||
|
||||
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 });
|
||||
|
||||
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"));
|
||||
});
|
||||
|
||||
test("suggests aggregation stages inside aggregate pipeline", () => {
|
||||
const items = buildMongoCompletionItems("db.users.aggregate([{ $m", "db.users.aggregate([{ $m".length);
|
||||
|
||||
assert.ok(items.find((item) => item.label === "$match" && item.detail === "aggregation stage"));
|
||||
});
|
||||
|
||||
test("completion context is tolerant of unfinished input", () => {
|
||||
const context = getMongoCompletionContext('db.getCollection("users").find({ "', 'db.getCollection("users").find({ "'.length);
|
||||
|
||||
assert.equal(context.mode, "field");
|
||||
assert.equal(context.collection, "users");
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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" }] },
|
||||
]);
|
||||
|
||||
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"));
|
||||
});
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
import { strict as assert } from "node:assert";
|
||||
import { test } from "vitest";
|
||||
import { formatMongoShellText, MAX_MONGO_FORMAT_CHARS } from "../../apps/desktop/src/lib/mongoFormatter.ts";
|
||||
|
||||
test("formats MongoDB find query objects", () => {
|
||||
assert.equal(
|
||||
formatMongoShellText('db.characters.find({name:"Ada",age:{$gte:18,$lt:30}}).sort({createdAt:-1}).limit(20)'),
|
||||
`db.characters.find({
|
||||
name: "Ada",
|
||||
age: {
|
||||
$gte: 18,
|
||||
$lt: 30
|
||||
}
|
||||
})
|
||||
.sort({
|
||||
createdAt: -1
|
||||
})
|
||||
.limit(20)`,
|
||||
);
|
||||
});
|
||||
|
||||
test("formats MongoDB aggregation pipelines", () => {
|
||||
assert.equal(
|
||||
formatMongoShellText('db.orders.aggregate([{$match:{status:"paid"}},{$group:{_id:"$userId",total:{$sum:"$amount"}}}])'),
|
||||
`db.orders.aggregate([
|
||||
{
|
||||
$match: {
|
||||
status: "paid"
|
||||
}
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: "$userId",
|
||||
total: {
|
||||
$sum: "$amount"
|
||||
}
|
||||
}
|
||||
}
|
||||
])`,
|
||||
);
|
||||
});
|
||||
|
||||
test("preserves strings, regex literals, and comments", () => {
|
||||
assert.equal(
|
||||
formatMongoShellText('db.users.find({name:/a,b\\/c/i,note:"x,y",active:true}) // keep comment'),
|
||||
`db.users.find({
|
||||
name: /a,b\\/c/i,
|
||||
note: "x,y",
|
||||
active: true
|
||||
}) // keep comment`,
|
||||
);
|
||||
});
|
||||
|
||||
test("respects tab indentation setting", () => {
|
||||
assert.equal(
|
||||
formatMongoShellText("db.users.find({name:'Ada'})", { useTabs: true, tabWidth: 4 }),
|
||||
"db.users.find({\n\tname: 'Ada'\n})",
|
||||
);
|
||||
});
|
||||
|
||||
test("leaves blank MongoDB text unchanged", () => {
|
||||
assert.equal(formatMongoShellText(" \n\t"), " \n\t");
|
||||
});
|
||||
|
||||
test("rejects very large MongoDB queries", () => {
|
||||
assert.throws(() => formatMongoShellText("x".repeat(MAX_MONGO_FORMAT_CHARS + 1)), /too large/i);
|
||||
});
|
||||
Loading…
Reference in New Issue