From ecf0d7fa8472cadd96bc0c2e49ddc4d8388425ec Mon Sep 17 00:00:00 2001 From: DasDarki Date: Wed, 5 Aug 2026 11:06:05 +0200 Subject: [PATCH] fix: aggregate and shell view, enhance auto completion --- README.md | 13 +- src/main/ipc/channels.ts | 5 +- src/main/ipc/router.ts | 18 + src/main/services/QueryService.ts | 72 +- src/preload/index.ts | 14 +- .../src/features/collection/CollectionTab.tsx | 366 +++++- .../src/features/collection/QueryEditor.tsx | 423 +------ .../src/features/collection/QueryToolbar.tsx | 97 +- .../src/features/collection/StatusBar.tsx | 8 + src/renderer/src/lib/api.ts | 14 +- src/renderer/src/lib/monacoCompletions.ts | 176 +++ src/renderer/src/lib/monacoSetup.ts | 3 + .../src/lib/mongoCompletionModel.test.ts | 407 ++++++ src/renderer/src/lib/mongoCompletionModel.ts | 1115 +++++++++++++++++ src/renderer/src/lib/shellParser.test.ts | 103 +- src/renderer/src/lib/shellParser.ts | 225 +++- src/shared/api.ts | 9 + src/shared/schemas.ts | 33 + src/shared/types.ts | 53 + 19 files changed, 2678 insertions(+), 476 deletions(-) create mode 100644 src/renderer/src/lib/monacoCompletions.ts create mode 100644 src/renderer/src/lib/mongoCompletionModel.test.ts create mode 100644 src/renderer/src/lib/mongoCompletionModel.ts diff --git a/README.md b/README.md index 2eeb1c3..901cfca 100644 --- a/README.md +++ b/README.md @@ -116,15 +116,16 @@ Per-database user management. Common-role shortcuts plus arbitrary custom roles. ### Query modes -| Mode | Surface | Highlights | -| --------------- | ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | -| **Simple** | filter · projection · sort · skip · limit | EJSON or shell syntax, field-name autocomplete from last results | -| **Aggregation** | pipeline as `[{ $match: … }, …]` | Monaco, full operator autocomplete | -| **Shell** | `db.coll.find({…}).sort({…}).limit(n)` | Curated subset of mongosh syntax — `find`, `findOne`, `aggregate`, `count`, `countDocuments`, `estimatedDocumentCount` | +| Mode | Surface | Highlights | +| --------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| **Simple** | filter · projection · sort · skip · limit | EJSON or shell syntax, field-name autocomplete from last results | +| **Aggregation** | pipeline as `[{ $match: … }, …]` | Monaco, full operator autocomplete | +| **Shell** | `db.coll.find({…}).sort({…}).limit(n)` | Curated subset of mongosh syntax - reads (`find`, `findOne`, `aggregate`, `count`, `countDocuments`) and writes (`insertOne`, `insertMany`, `updateOne`, `updateMany`, `replaceOne`, `deleteOne`, `deleteMany`), across any collection of the database | - **Run:** click button or `Ctrl+Enter` / `Cmd+Enter` - **Format:** `Shift+Alt+F` — custom formatter that preserves shell helpers -- **Operator autocomplete** for every standard query operator (`$eq`, `$in`, `$elemMatch`, `$geoWithin`, `$jsonSchema`, …) +- **Context-aware autocomplete:** query operators in filters, stages and expressions in pipelines, update operators inside `updateOne` / `updateMany`, `db` → collection → method chaining in the shell, plus parameter hints and hover docs +- **Guardrails:** write commands never run on a refetch, only on an explicit Run, and `deleteMany({})` / `updateMany({})` ask before touching the whole collection ### Documents diff --git a/src/main/ipc/channels.ts b/src/main/ipc/channels.ts index ffc37b0..f065d59 100644 --- a/src/main/ipc/channels.ts +++ b/src/main/ipc/channels.ts @@ -39,7 +39,10 @@ export const Channels = { QueryInsertOne: 'query:insertOne', QueryInsertMany: 'query:insertMany', QueryDeleteOne: 'query:deleteOne', - QueryDeleteMany: 'query:deleteMany' + QueryDeleteMany: 'query:deleteMany', + QueryUpdateByFilter: 'query:updateByFilter', + QueryDeleteByFilter: 'query:deleteByFilter', + QueryReplaceByFilter: 'query:replaceByFilter' } as const export type ChannelName = (typeof Channels)[keyof typeof Channels] diff --git a/src/main/ipc/router.ts b/src/main/ipc/router.ts index 1569945..3dc4a25 100644 --- a/src/main/ipc/router.ts +++ b/src/main/ipc/router.ts @@ -15,6 +15,7 @@ import { CreateIndexSchema, CreateUserSchema, DatabaseRefSchema, + DeleteByFilterRequestSchema, DeleteManyRequestSchema, DeleteOneRequestSchema, DropIndexSchema, @@ -25,7 +26,9 @@ import { InsertOneRequestSchema, RenameCollectionSchema, ReorderConnectionsSchema, + ReplaceByFilterRequestSchema, ReplaceOneRequestSchema, + UpdateByFilterRequestSchema, UpdateUserSchema } from '@shared/schemas' import type { Result } from '@shared/result' @@ -228,6 +231,21 @@ export function registerIpcHandlers(services: Services): void { withResult(DeleteManyRequestSchema, (request) => queries.deleteMany(request)) ) + ipcMain.handle( + Channels.QueryUpdateByFilter, + withResult(UpdateByFilterRequestSchema, (request) => queries.updateByFilter(request)) + ) + + ipcMain.handle( + Channels.QueryDeleteByFilter, + withResult(DeleteByFilterRequestSchema, (request) => queries.deleteByFilter(request)) + ) + + ipcMain.handle( + Channels.QueryReplaceByFilter, + withResult(ReplaceByFilterRequestSchema, (request) => queries.replaceByFilter(request)) + ) + ipcMain.handle( Channels.IndexesList, withResult(IndexesListSchema, ({ connectionId, db, coll }) => diff --git a/src/main/services/QueryService.ts b/src/main/services/QueryService.ts index 31b27b6..c287a50 100644 --- a/src/main/services/QueryService.ts +++ b/src/main/services/QueryService.ts @@ -1,8 +1,10 @@ -import type { Document, Filter, Sort } from 'mongodb' +import type { Document, Filter, Sort, UpdateFilter } from 'mongodb' import { EJSON } from 'bson' import type { AggregateRequest, AggregateResponse, + DeleteByFilterRequest, + DeleteByFilterResponse, DeleteManyRequest, DeleteManyResponse, DeleteOneRequest, @@ -14,8 +16,12 @@ import type { InsertManyResponse, InsertOneRequest, InsertOneResponse, + ReplaceByFilterRequest, + ReplaceByFilterResponse, ReplaceOneRequest, - ReplaceOneResponse + ReplaceOneResponse, + UpdateByFilterRequest, + UpdateByFilterResponse } from '@shared/types' import type { ConnectionService } from './ConnectionService' import { canonicalHash, parseFilter, toCanonicalString, toRelaxed } from '../lib/ejson' @@ -155,6 +161,46 @@ export class QueryService { const result = await coll.deleteOne(idFilter) return { deletedCount: result.deletedCount } } + + async updateByFilter(req: UpdateByFilterRequest): Promise { + const client = this.connections.getClient(req.connectionId) + const coll = client.db(req.db).collection(req.coll) + const filter = parseFilter(req.filter) as Filter + const update = parseUpdate(req.update) + + const result = req.many + ? await coll.updateMany(filter, update, { upsert: req.upsert }) + : await coll.updateOne(filter, update, { upsert: req.upsert }) + + return { + matched: result.matchedCount, + modified: result.modifiedCount, + upsertedId: result.upsertedId ? toCanonicalString(result.upsertedId) : null + } + } + + async deleteByFilter(req: DeleteByFilterRequest): Promise { + const client = this.connections.getClient(req.connectionId) + const coll = client.db(req.db).collection(req.coll) + const filter = parseFilter(req.filter) as Filter + + const result = req.many ? await coll.deleteMany(filter) : await coll.deleteOne(filter) + return { deletedCount: result.deletedCount } + } + + async replaceByFilter(req: ReplaceByFilterRequest): Promise { + const client = this.connections.getClient(req.connectionId) + const coll = client.db(req.db).collection(req.coll) + const filter = parseFilter(req.filter) as Filter + const replacement = parseDocument(req.replacement) + + const result = await coll.replaceOne(filter, replacement, { upsert: req.upsert }) + return { + matched: result.matchedCount, + modified: result.modifiedCount, + upsertedId: result.upsertedId ? toCanonicalString(result.upsertedId) : null + } + } } function toEnvelope(doc: Document): DocumentEnvelope { @@ -181,6 +227,28 @@ function parseDocument(canonical: string): Document { return parsed as Document } +/** + * Update documents must consist of update operators (`$set`, `$inc`, …) or + * be an aggregation pipeline. A plain document would silently replace the + * whole record, which is what `replaceByFilter` is for. + */ +function parseUpdate(canonical: string): UpdateFilter | Document[] { + const parsed = EJSON.parse(canonical, { relaxed: false }) + if (Array.isArray(parsed)) return parsePipeline(canonical) + if (typeof parsed !== 'object' || parsed === null) { + const e = new Error('Update must be a JSON object') + e.name = 'ValidationError' + throw e + } + const keys = Object.keys(parsed) + if (keys.length === 0 || !keys.every((key) => key.startsWith('$'))) { + const e = new Error('Update must only contain update operators such as $set or $inc') + e.name = 'ValidationError' + throw e + } + return parsed as UpdateFilter +} + function parsePipeline(canonical: string): Document[] { const parsed = EJSON.parse(canonical, { relaxed: false }) if (!Array.isArray(parsed)) { diff --git a/src/preload/index.ts b/src/preload/index.ts index c8fd8e1..e14938d 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -16,6 +16,8 @@ import type { CreateUserPayload, DatabaseInfo, DatabaseUser, + DeleteByFilterRequest, + DeleteByFilterResponse, DeleteManyRequest, DeleteManyResponse, DeleteOneRequest, @@ -30,9 +32,13 @@ import type { InsertOneRequest, InsertOneResponse, RenameCollectionPayload, + ReplaceByFilterRequest, + ReplaceByFilterResponse, ReplaceOneRequest, ReplaceOneResponse, ServerStats, + UpdateByFilterRequest, + UpdateByFilterResponse, UpdateUserPayload } from '@shared/types' @@ -83,7 +89,13 @@ const api: Api = { invoke('query:insertMany', request), deleteOne: (request: DeleteOneRequest) => invoke('query:deleteOne', request), deleteMany: (request: DeleteManyRequest) => - invoke('query:deleteMany', request) + invoke('query:deleteMany', request), + updateByFilter: (request: UpdateByFilterRequest) => + invoke('query:updateByFilter', request), + deleteByFilter: (request: DeleteByFilterRequest) => + invoke('query:deleteByFilter', request), + replaceByFilter: (request: ReplaceByFilterRequest) => + invoke('query:replaceByFilter', request) }, users: { list: (payload: { connectionId: string; db: string }) => diff --git a/src/renderer/src/features/collection/CollectionTab.tsx b/src/renderer/src/features/collection/CollectionTab.tsx index 82ce6a9..a6b5ec4 100644 --- a/src/renderer/src/features/collection/CollectionTab.tsx +++ b/src/renderer/src/features/collection/CollectionTab.tsx @@ -1,13 +1,31 @@ -import { useMemo } from 'react' -import { useQuery, useQueryClient } from '@tanstack/react-query' -import { ServerCrash } from 'lucide-react' +import { useMemo, useState } from 'react' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { CheckCircle2, Layers, Loader2, Pencil, ServerCrash, TerminalSquare } from 'lucide-react' +import { toast } from 'sonner' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle +} from '@/components/ui/alert-dialog' import { api, ApiError } from '@/lib/api' import { queryKeys } from '@/lib/queryClient' import { parseMongoQuery } from '@/lib/mongoQueryLang' -import { parseShellCommand } from '@/lib/shellParser' +import { + affectsWholeCollection, + isWriteOp, + parseShellCommand, + type ShellWriteOp, + type ShellWriteRequest +} from '@/lib/shellParser' import { useTabsStore, type CollectionTab as CollectionTabType, + type QueryMode, type QueryPatch } from '@/store/tabs' import { QueryToolbar } from './QueryToolbar' @@ -39,6 +57,7 @@ export function CollectionTab({ tab }: { tab: CollectionTabType }) { return { mode: 'simple' as const, ok, + idle: false, filter: f.ok ? f.ejson : null, projection: p.ok ? p.ejson : null, sort: s.ok ? s.ejson : null, @@ -50,35 +69,46 @@ export function CollectionTab({ tab }: { tab: CollectionTabType }) { } } if (tab.mode === 'aggregation') { + if (tab.pipeline.trim().length === 0) { + return { mode: 'aggregation' as const, ok: false, idle: true, pipeline: null, error: null } + } const r = parseMongoQuery(tab.pipeline) if (!r.ok) { - return { mode: 'aggregation' as const, ok: false, pipeline: null, error: r.error } + return { + mode: 'aggregation' as const, + ok: false, + idle: false, + pipeline: null, + error: r.error + } } if (!Array.isArray(r.value)) { return { mode: 'aggregation' as const, ok: false, + idle: false, pipeline: null, error: 'Pipeline must be an array' } } - return { mode: 'aggregation' as const, ok: true, pipeline: r.ejson, error: null } + return { mode: 'aggregation' as const, ok: true, idle: false, pipeline: r.ejson, error: null } + } + if (tab.shell.trim().length === 0) { + return { mode: 'shell' as const, ok: false, idle: true, parsed: null, error: null } } - // shell const r = parseShellCommand(tab.shell) if (!r.ok) { - return { mode: 'shell' as const, ok: false, parsed: null, error: r.error } + return { mode: 'shell' as const, ok: false, idle: false, parsed: null, error: r.error } } - if (r.coll !== tab.coll) { - return { - mode: 'shell' as const, - ok: false, - parsed: null, - error: `Command targets "${r.coll}" but this tab is "${tab.coll}"` - } - } - return { mode: 'shell' as const, ok: true, parsed: r, error: null } - }, [tab.mode, tab.filter, tab.projection, tab.sort, tab.pipeline, tab.shell, tab.coll]) + return { mode: 'shell' as const, ok: true, idle: false, parsed: r, error: null } + }, [tab.mode, tab.filter, tab.projection, tab.sort, tab.pipeline, tab.shell]) + + // A shell command may target another collection than the tab it was + // typed in; every read and every row action has to follow it. + const effectiveColl = + compiled.mode === 'shell' && compiled.parsed ? compiled.parsed.coll : tab.coll + const shellOp = compiled.mode === 'shell' ? (compiled.parsed?.op ?? null) : null + const writeOp: ShellWriteOp | null = shellOp && isWriteOp(shellOp) ? shellOp : null const findQuery = useQuery({ queryKey: @@ -97,8 +127,9 @@ export function CollectionTab({ tab }: { tab: CollectionTabType }) { : compiled.mode === 'aggregation' ? queryKeys.aggregate(tab.connectionId, tab.db, tab.coll, tab.pipeline, tab.runEpoch) : queryKeys.shell(tab.connectionId, tab.db, tab.coll, tab.shell, tab.runEpoch), - queryFn: () => runForMode(tab, compiled), - enabled: compiled.ok, + queryFn: () => runForMode(tab, compiled, effectiveColl), + // Writes never run as a query — a query may refetch, a write may not. + enabled: compiled.ok && writeOp === null, // Don't auto-refetch when the user just switches between tabs; the // query only re-runs when its key changes (filter / mode / runEpoch). refetchOnMount: false, @@ -119,6 +150,43 @@ export function CollectionTab({ tab }: { tab: CollectionTabType }) { enabled: compiled.mode === 'simple' && compiled.ok }) + const [writeResult, setWriteResult] = useState<{ + command: string + outcome: ShellWriteOutcome + } | null>(null) + const [pendingWrite, setPendingWrite] = useState(null) + + const writeMutation = useMutation({ + mutationFn: (request: ShellWriteRequest) => + runWrite(tab.connectionId, tab.db, request.coll, request.op), + onSuccess: (outcome, request) => { + setWriteResult({ command: request.command, outcome }) + setPendingWrite(null) + void queryClient.invalidateQueries({ queryKey: ['find'] }) + void queryClient.invalidateQueries({ queryKey: ['count'] }) + void queryClient.invalidateQueries({ queryKey: ['aggregate'] }) + void queryClient.invalidateQueries({ queryKey: ['shell'] }) + void queryClient.invalidateQueries({ queryKey: ['collection-stats'] }) + void queryClient.invalidateQueries({ + queryKey: queryKeys.collections(tab.connectionId, tab.db) + }) + toast.success(outcome.title) + }, + onError: (error) => { + setPendingWrite(null) + toast.error(error instanceof ApiError ? error.message : 'Command failed') + } + }) + + const runWriteCommand = (request: ShellWriteRequest) => { + if (writeMutation.isPending) return + if (affectsWholeCollection(request.op)) { + setPendingWrite(request) + return + } + writeMutation.mutate(request) + } + const apply = (patch: QueryPatch) => setQuery(tab.id, patch) const cancel = () => { @@ -146,14 +214,16 @@ export function CollectionTab({ tab }: { tab: CollectionTabType }) { const documents = findQuery.data?.documents ?? [] const tookMs = findQuery.data?.tookMs + const currentWriteResult = writeResult?.command === tab.shell ? writeResult.outcome : null return (
setQuery(tab.id, { skip })} />
- {compiled.error ? ( + {compiled.idle ? ( + + ) : compiled.error ? ( + ) : writeOp ? ( + ) : findQuery.error instanceof ApiError ? ( ) : ( @@ -186,12 +266,51 @@ export function CollectionTab({ tab }: { tab: CollectionTabType }) { loading={findQuery.isFetching} connectionId={tab.connectionId} db={tab.db} - coll={tab.coll} + coll={effectiveColl} uuidEncoding={uuidEncoding} timezone={timezone} /> )}
+ + { + if (open || writeMutation.isPending) return + setPendingWrite(null) + }} + > + + + + {pendingWrite?.op.kind === 'deleteMany' + ? 'Delete every document?' + : 'Update every document?'} + + + The command has an empty filter and therefore hits every document in{' '} + + {tab.db}.{pendingWrite?.coll} + + . This action cannot be undone. + + + + Cancel + { + e.preventDefault() + if (pendingWrite) writeMutation.mutate(pendingWrite) + }} + > + {writeMutation.isPending && } + Run anyway + + + +
) } @@ -200,25 +319,37 @@ type Compiled = | { mode: 'simple' ok: boolean + idle: boolean filter: string | null projection: string | null sort: string | null error: string | null } - | { mode: 'aggregation'; ok: boolean; pipeline: string | null; error: string | null } + | { + mode: 'aggregation' + ok: boolean + idle: boolean + pipeline: string | null + error: string | null + } | { mode: 'shell' ok: boolean + idle: boolean parsed: (ReturnType & { ok: true }) | null error: string | null } -async function runForMode(tab: CollectionTabType, compiled: Compiled): Promise { +async function runForMode( + tab: CollectionTabType, + compiled: Compiled, + coll: string +): Promise { if (compiled.mode === 'simple') { return api.query.find({ connectionId: tab.connectionId, db: tab.db, - coll: tab.coll, + coll, ...(compiled.filter ? { filter: compiled.filter } : {}), ...(compiled.projection ? { projection: compiled.projection } : {}), ...(compiled.sort ? { sort: compiled.sort } : {}), @@ -231,7 +362,7 @@ async function runForMode(tab: CollectionTabType, compiled: Compiled): Promise { + const target = { connectionId, db, coll } + switch (op.kind) { + case 'insertOne': { + const result = await api.query.insertOne({ ...target, document: op.document }) + return { title: '1 document inserted', details: [`insertedId: ${result.insertedId}`] } + } + case 'insertMany': { + const result = await api.query.insertMany({ ...target, documents: op.documents }) + return { + title: `${plural(result.insertedIds.length, 'document')} inserted`, + details: result.insertedIds.slice(0, 20).map((id) => `insertedId: ${id}`) + } + } + case 'updateOne': + case 'updateMany': { + const result = await api.query.updateByFilter({ + ...target, + filter: op.filter, + update: op.update, + many: op.kind === 'updateMany', + upsert: op.upsert + }) + return { + title: `${plural(result.modified, 'document')} modified`, + details: [ + `matched: ${result.matched}`, + ...(result.upsertedId ? [`upsertedId: ${result.upsertedId}`] : []) + ] } - ], - tookMs: 0 + } + case 'replaceOne': { + const result = await api.query.replaceByFilter({ + ...target, + filter: op.filter, + replacement: op.replacement, + upsert: op.upsert + }) + return { + title: `${plural(result.modified, 'document')} replaced`, + details: [ + `matched: ${result.matched}`, + ...(result.upsertedId ? [`upsertedId: ${result.upsertedId}`] : []) + ] + } + } + case 'deleteOne': + case 'deleteMany': { + const result = await api.query.deleteByFilter({ + ...target, + filter: op.filter, + many: op.kind === 'deleteMany' + }) + return { title: `${plural(result.deletedCount, 'document')} deleted`, details: [] } + } } } +function plural(count: number, noun: string): string { + return `${count} ${noun}${count === 1 ? '' : 's'}` +} + +function WriteState({ + op, + coll, + pending, + outcome +}: { + op: ShellWriteOp + coll: string + pending: boolean + outcome: ShellWriteOutcome | null +}) { + if (pending) { + return ( +
+ + Running {op.kind}… +
+ ) + } + if (outcome) { + return ( +
+ +
{outcome.title}
+ {outcome.details.length > 0 && ( +
+ {outcome.details.map((line) => ( +
{line}
+ ))} +
+ )} +
+ ) + } + return ( +
+ +
+ {op.kind} on{' '} + {coll} is ready. Press Run to execute it. +
+
+ ) +} + +function IdleState({ mode, coll }: { mode: QueryMode; coll: string }) { + const isShell = mode === 'shell' + return ( +
+ {isShell ? ( + + ) : ( + + )} +
+ {isShell + ? 'Write a shell command above and press Run.' + : 'Build an aggregation pipeline above and press Run.'} +
+ + {isShell ? `db.${coll}.find({}).limit(50)` : '[{ $match: { … } }]'} + +
+ ) +} + function ErrorState({ message }: { message: string }) { return (
diff --git a/src/renderer/src/features/collection/QueryEditor.tsx b/src/renderer/src/features/collection/QueryEditor.tsx index 1a0ed4f..e196d06 100644 --- a/src/renderer/src/features/collection/QueryEditor.tsx +++ b/src/renderer/src/features/collection/QueryEditor.tsx @@ -1,7 +1,13 @@ import { useEffect, useRef, useState } from 'react' import Editor, { type OnMount } from '@monaco-editor/react' -import * as monaco from 'monaco-editor' +import type * as monaco from 'monaco-editor' import { cn } from '@/lib/utils' +import { + clearEditorCompletionContext, + setEditorCompletionContext, + QUERY_COMPLETION_CONTEXT, + type EditorCompletionContext +} from '@/lib/monacoCompletions' type Props = { value: string @@ -16,6 +22,7 @@ type Props = { /** Render the Run / Format actions absolutely over the editor's right edge. */ actions?: React.ReactNode autoFocus?: boolean + completionContext?: EditorCompletionContext /** * Called when the user presses Shift+Alt+F. If provided, overrides * Monaco's built-in JSON formatter — useful because Monaco's JSON @@ -33,9 +40,8 @@ type Props = { * MongoDB filter editor. * * Wraps Monaco with: JSON syntax highlighting, bracket auto-pairing, - * format-on-paste, MongoDB operator IntelliSense (registered once via - * `ensureProviderRegistered`), and a destructive-coloured border when - * the value isn't valid JSON. + * format-on-paste, MongoDB IntelliSense scoped to `completionContext`, + * and a destructive-coloured border when the value isn't valid JSON. */ export function QueryEditor({ value, @@ -47,12 +53,15 @@ export function QueryEditor({ maxHeight = 140, actions, autoFocus, + completionContext = QUERY_COMPLETION_CONTEXT, onFormat, onContentHeightChange }: Props) { const editorRef = useRef(null) + const modelUriRef = useRef(null) const submitRef = useRef(onSubmit) const formatRef = useRef(onFormat) + const completionContextRef = useRef(completionContext) const minHeightRef = useRef(minHeight) const maxHeightRef = useRef(maxHeight) const onContentHeightChangeRef = useRef(onContentHeightChange) @@ -66,6 +75,18 @@ export function QueryEditor({ useEffect(() => { onContentHeightChangeRef.current = onContentHeightChange }, [onContentHeightChange]) + useEffect(() => { + completionContextRef.current = completionContext + const uri = modelUriRef.current + if (uri) setEditorCompletionContext(uri, completionContext) + }, [completionContext]) + useEffect( + () => () => { + const uri = modelUriRef.current + if (uri) clearEditorCompletionContext(uri) + }, + [] + ) // Re-clamp the visible height when the bounds change from outside — // e.g. a sister editor pushed our `minHeight` up to keep both rows the // same height. @@ -80,7 +101,10 @@ export function QueryEditor({ const handleMount: OnMount = (editor, m) => { editorRef.current = editor - ensureProviderRegistered() + + const uri = editor.getModel()?.uri.toString() ?? null + modelUriRef.current = uri + if (uri) setEditorCompletionContext(uri, completionContextRef.current) editor.addCommand(m.KeyMod.CtrlCmd | m.KeyCode.Enter, () => submitRef.current()) editor.addAction({ @@ -159,6 +183,7 @@ export function QueryEditor({ suggestOnTriggerCharacters: true, acceptSuggestionOnEnter: 'smart', tabCompletion: 'on', + wordBasedSuggestions: 'off', autoClosingBrackets: 'always', autoClosingQuotes: 'always', autoSurround: 'languageDefined', @@ -179,391 +204,3 @@ export function QueryEditor({
) } - -let providerRegistered = false - -// Distinct top-level keys from the most recently fetched documents. The -// completion provider reads this directly so it doesn't have to be -// re-registered on every fetch — refreshing the cache is enough. -let documentFieldNames: string[] = [] - -export function setDocumentFieldNames(names: Iterable): void { - documentFieldNames = Array.from(new Set(names)).sort((a, b) => a.localeCompare(b)) -} - -function ensureProviderRegistered(): void { - if (providerRegistered) return - providerRegistered = true - - monaco.languages.registerCompletionItemProvider('mongo-shell', { - triggerCharacters: ['$', '"', 'I', 'O', 'N', 'U', 'D', 'B', 'T', 'M', '{', ',', ' '], - provideCompletionItems(model, position) { - const lineUpToCursor = model.getValueInRange({ - startLineNumber: position.lineNumber, - startColumn: 1, - endLineNumber: position.lineNumber, - endColumn: position.column - }) - - // Three trigger contexts: - // - `$foo` (optionally with leading `"`) → MongoDB query operator - // - bare identifier prefix → mongo shell helper (ObjectId, ISODate, …) - // - after `{` or `,` (object key position) → cached document field names - const opMatch = /"?\$[A-Za-z]*$/.exec(lineUpToCursor) - const helperMatch = /(?:^|[\s:,[(])([A-Za-z][A-Za-z0-9]*)$/.exec(lineUpToCursor) - const helperPrefix = helperMatch ? helperMatch[1]! : null - const keyMatch = /(?:^|[{,])\s*("?)([A-Za-z_$][\w.]*)?$/.exec(lineUpToCursor) - - if (opMatch) { - const range = new monaco.Range( - position.lineNumber, - position.column - opMatch[0].length, - position.lineNumber, - position.column - ) - const suggestions = buildMongoCompletions().map( - (c): monaco.languages.CompletionItem => ({ - label: c.label, - kind: c.kind ?? monaco.languages.CompletionItemKind.Keyword, - insertText: c.insertText, - insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, - documentation: { value: c.doc }, - detail: c.detail, - range, - sortText: c.sortText ?? c.label - }) - ) - return { suggestions } - } - - const suggestions: monaco.languages.CompletionItem[] = [] - - if (keyMatch && documentFieldNames.length > 0) { - const hasQuote = keyMatch[1] === '"' - const partial = keyMatch[2] ?? '' - const startCol = position.column - partial.length - (hasQuote ? 1 : 0) - const range = new monaco.Range( - position.lineNumber, - startCol, - position.lineNumber, - position.column - ) - for (const name of documentFieldNames) { - suggestions.push({ - label: name, - kind: monaco.languages.CompletionItemKind.Field, - insertText: `"${name}"`, - filterText: name, - detail: 'Document field', - range, - // Prefix '0' so field names sort above operators / helpers when - // both contexts overlap (e.g. user typed a bare letter). - sortText: `0_${name}` - }) - } - } - - if (helperPrefix) { - const range = new monaco.Range( - position.lineNumber, - position.column - helperPrefix.length, - position.lineNumber, - position.column - ) - for (const c of buildShellHelpers()) { - suggestions.push({ - label: c.label, - kind: c.kind ?? monaco.languages.CompletionItemKind.Keyword, - insertText: c.insertText, - insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, - documentation: { value: c.doc }, - detail: c.detail, - range, - sortText: c.sortText ?? c.label - }) - } - } - - return { suggestions } - } - }) -} - -type CompletionDef = { - label: string - insertText: string - doc: string - detail: string - kind?: monaco.languages.CompletionItemKind - sortText?: string -} - -// Always wraps in `"…"` to produce valid JSON keys regardless of whether -// the user already typed an opening quote (range covers the leading `"`). -const op = ( - label: string, - insertText: string, - doc: string, - detail = 'MongoDB query operator' -): CompletionDef => ({ - label, - insertText: `"${insertText}"`.replace(/^""/, '"'), - doc, - detail, - kind: monaco.languages.CompletionItemKind.Function -}) - -// Comparison -const COMPARISON: CompletionDef[] = [ - op('$eq', '$eq": ${1:value}', 'Matches values equal to a specified value.'), - op('$ne', '$ne": ${1:value}', 'Matches values not equal to a specified value.'), - op('$gt', '$gt": ${1:value}', 'Matches values greater than a specified value.'), - op('$gte', '$gte": ${1:value}', 'Matches values greater than or equal to a specified value.'), - op('$lt', '$lt": ${1:value}', 'Matches values less than a specified value.'), - op('$lte', '$lte": ${1:value}', 'Matches values less than or equal to a specified value.'), - op('$in', '$in": [${1:values}]', 'Matches any of the values in the array.'), - op('$nin', '$nin": [${1:values}]', 'Matches none of the values in the array.') -] - -// Logical -const LOGICAL: CompletionDef[] = [ - op('$and', '$and": [\n\t{ $0 }\n]', 'Joins clauses with logical AND.'), - op('$or', '$or": [\n\t{ $0 }\n]', 'Joins clauses with logical OR.'), - op('$nor', '$nor": [\n\t{ $0 }\n]', 'Joins clauses with logical NOR.'), - op('$not', '$not": { $0 }', 'Inverts the effect of an expression.') -] - -// Element -const ELEMENT: CompletionDef[] = [ - op('$exists', '$exists": ${1|true,false|}', 'Matches documents that have or lack the field.'), - op( - '$type', - '$type": "${1|double,string,object,array,binData,objectId,bool,date,null,regex,int,timestamp,long,decimal,minKey,maxKey|}"', - 'Matches documents whose field is one of the BSON types.' - ) -] - -// Evaluation -const EVALUATION: CompletionDef[] = [ - op('$expr', '$expr": { $0 }', 'Allows the use of aggregation expressions inside the query.'), - op('$jsonSchema', '$jsonSchema": { $0 }', 'Validates documents against a JSON Schema.'), - op('$mod', '$mod": [${1:divisor}, ${2:remainder}]', 'Performs a modulo operation.'), - op('$regex', '$regex": "${1:pattern}", "$options": "${2:i}"', 'Pattern match against a regex.'), - op('$options', '$options": "${1:i}"', 'Regex options: i (case-insensitive), m, x, s.'), - op('$text', '$text": { "$search": "${1:query}" }', 'Performs a text search.'), - op('$search', '$search": "${1:query}"', 'Search string for a $text query.'), - op('$language', '$language": "${1:english}"', 'Language to use for the text search.'), - op('$caseSensitive', '$caseSensitive": ${1|true,false|}', 'Case-sensitive text search.'), - op( - '$diacriticSensitive', - '$diacriticSensitive": ${1|true,false|}', - 'Diacritic-sensitive text search.' - ), - op( - '$where', - '$where": "${1:function() { return true; }}"', - 'Server-side JavaScript predicate (slow!).' - ) -] - -// Array -const ARRAY_OPS: CompletionDef[] = [ - op('$all', '$all": [${1:values}]', 'Matches arrays containing all the specified elements.'), - op( - '$elemMatch', - '$elemMatch": { $0 }', - 'Matches arrays with at least one element matching all criteria.' - ), - op('$size', '$size": ${1:n}', 'Matches arrays of the given length.') -] - -// Bitwise -const BITWISE: CompletionDef[] = [ - op('$bitsAllClear', '$bitsAllClear": ${1:mask}', 'All bits in the mask are clear (0).'), - op('$bitsAllSet', '$bitsAllSet": ${1:mask}', 'All bits in the mask are set (1).'), - op('$bitsAnyClear', '$bitsAnyClear": ${1:mask}', 'Any bit in the mask is clear.'), - op('$bitsAnySet', '$bitsAnySet": ${1:mask}', 'Any bit in the mask is set.') -] - -// Geospatial -const GEO: CompletionDef[] = [ - op( - '$geoIntersects', - '$geoIntersects": { "$geometry": { "type": "${1:Point}", "coordinates": [${2:0}, ${3:0}] } }', - 'Selects geometries that intersect with a GeoJSON geometry.' - ), - op( - '$geoWithin', - '$geoWithin": { "$geometry": { "type": "${1:Polygon}", "coordinates": $2 } }', - 'Selects geometries within a bounding GeoJSON geometry.' - ), - op( - '$near', - '$near": { "$geometry": { "type": "Point", "coordinates": [${1:lng}, ${2:lat}] }, "$maxDistance": ${3:meters} }', - 'Returns docs ordered by proximity to a point.' - ), - op( - '$nearSphere', - '$nearSphere": { "$geometry": { "type": "Point", "coordinates": [${1:lng}, ${2:lat}] }, "$maxDistance": ${3:meters} }', - 'Like $near but uses spherical geometry.' - ), - op( - '$geometry', - '$geometry": { "type": "${1:Point}", "coordinates": ${2} }', - 'GeoJSON geometry helper.' - ), - op('$maxDistance', '$maxDistance": ${1:meters}', 'Max distance (meters) from $near point.'), - op('$minDistance', '$minDistance": ${1:meters}', 'Min distance (meters) from $near point.'), - op( - '$box', - '$box": [[${1:lngLow}, ${2:latLow}], [${3:lngHigh}, ${4:latHigh}]]', - 'Legacy bounding box for $geoWithin.' - ), - op( - '$center', - '$center": [[${1:lng}, ${2:lat}], ${3:radius}]', - 'Legacy circle (flat) for $geoWithin.' - ), - op( - '$centerSphere', - '$centerSphere": [[${1:lng}, ${2:lat}], ${3:radians}]', - 'Spherical circle for $geoWithin.' - ), - op( - '$polygon', - '$polygon": [[${1:lng1}, ${2:lat1}], [${3:lng2}, ${4:lat2}], [${5:lng3}, ${6:lat3}]]', - 'Legacy polygon for $geoWithin.' - ) -] - -// Projection / cursor modifiers (used in projection field, but also valid in find spec) -const PROJECTION: CompletionDef[] = [ - op('$slice', '$slice": ${1:n}', 'Limit array elements in projection.', 'Projection operator'), - op('$meta', '$meta": "${1|textScore,indexKey|}"', 'Project metadata.', 'Projection operator') -] - -// EJSON wrappers — for value position (BSON types) -function buildEjsonWrappers(): CompletionDef[] { - const nowIso = new Date().toISOString() - const nowSec = Math.floor(Date.now() / 1000) - return [ - op( - '$oid', - '$oid": "${1:507f1f77bcf86cd799439011}"', - 'EJSON: ObjectId wrapper.', - 'EJSON / BSON type' - ), - op( - '$date', - `$date": "\${1:${nowIso}}"`, - 'EJSON: Date wrapper (ISO-8601 string or { "$numberLong": "ms" }).', - 'EJSON / BSON type' - ), - op('$numberLong', '$numberLong": "${1:0}"', 'EJSON: 64-bit integer.', 'EJSON / BSON type'), - op('$numberInt', '$numberInt": "${1:0}"', 'EJSON: 32-bit integer.', 'EJSON / BSON type'), - op('$numberDouble', '$numberDouble": "${1:0.0}"', 'EJSON: 64-bit float.', 'EJSON / BSON type'), - op( - '$numberDecimal', - '$numberDecimal": "${1:0}"', - 'EJSON: 128-bit decimal.', - 'EJSON / BSON type' - ), - op( - '$binary', - '$binary": { "base64": "${1:}", "subType": "${2:00}" }', - 'EJSON: BSON binary.', - 'EJSON / BSON type' - ), - op( - '$timestamp', - `$timestamp": { "t": \${1:${nowSec}}, "i": \${2:0} }`, - 'EJSON: BSON timestamp (replication).', - 'EJSON / BSON type' - ), - op( - '$uuid', - '$uuid": "${1:00000000-0000-0000-0000-000000000000}"', - 'EJSON: UUID (BSON binary subType 4).', - 'EJSON / BSON type' - ), - op( - '$regularExpression', - '$regularExpression": { "pattern": "${1:}", "options": "${2:i}" }', - 'EJSON: BSON regex (canonical).', - 'EJSON / BSON type' - ), - op('$symbol', '$symbol": "${1:}"', 'EJSON: deprecated BSON symbol.', 'EJSON / BSON type'), - op('$code', '$code": "${1:function() {}}"', 'EJSON: BSON Code.', 'EJSON / BSON type'), - op('$minKey', '$minKey": 1', 'EJSON: BSON MinKey marker.', 'EJSON / BSON type'), - op('$maxKey', '$maxKey": 1', 'EJSON: BSON MaxKey marker.', 'EJSON / BSON type'), - op('$undefined', '$undefined": true', 'EJSON: deprecated BSON Undefined.', 'EJSON / BSON type') - ] -} - -function buildMongoCompletions(): CompletionDef[] { - return [ - ...COMPARISON, - ...LOGICAL, - ...ELEMENT, - ...EVALUATION, - ...ARRAY_OPS, - ...BITWISE, - ...GEO, - ...PROJECTION, - ...buildEjsonWrappers() - ] -} - -// Shell helpers — bare identifiers the user types in value position. -// The mongoQueryLang parser rewrites these into EJSON wrappers before the -// query is sent to the backend, so suggesting them as snippets is safe. -const helper = ( - label: string, - insertText: string, - doc: string, - detail = 'mongo shell helper' -): CompletionDef => ({ - label, - insertText, - doc, - detail, - kind: monaco.languages.CompletionItemKind.Constructor -}) - -function buildShellHelpers(): CompletionDef[] { - const nowIso = new Date().toISOString() - const nowSec = Math.floor(Date.now() / 1000) - return [ - helper('ObjectId', 'ObjectId("${1:507f1f77bcf86cd799439011}")', 'ObjectId hex literal.'), - helper('ISODate', `ISODate("\${1:${nowIso}}")`, 'ISO-8601 date — defaults to now.'), - helper('Date', `Date("\${1:${nowIso}}")`, 'Same as ISODate — defaults to now.'), - helper('NumberLong', 'NumberLong("${1:0}")', 'BSON 64-bit integer.'), - helper('NumberInt', 'NumberInt(${1:0})', 'BSON 32-bit integer.'), - helper('NumberDecimal', 'NumberDecimal("${1:0}")', 'BSON 128-bit decimal.'), - helper( - 'UUID', - 'UUID("${1:00000000-0000-0000-0000-000000000000}")', - 'UUID literal (BSON subType 04).' - ), - helper( - 'JUUID', - 'JUUID("${1:00000000-0000-0000-0000-000000000000}")', - 'Legacy Java-driver UUID (BSON subType 03, Java byte order).' - ), - helper('BinData', 'BinData(${1:0}, "${2:base64==}")', 'Binary data with subType.'), - helper('Timestamp', `Timestamp(\${1:${nowSec}}, \${2:0})`, 'BSON timestamp (replication).'), - helper('MinKey', 'MinKey', 'Sorts before any other BSON value.'), - helper('MaxKey', 'MaxKey', 'Sorts after any other BSON value.'), - helper('DBRef', 'DBRef("${1:coll}", ${2:id})', 'Document reference.'), - helper('Code', 'Code("${1:function() {}}")', 'BSON Code value.'), - helper( - 'RegExp', - 'RegExp("${1:pattern}", "${2:i}")', - 'Regex; equivalent to a /pattern/flags literal.' - ), - helper('true', 'true', 'Boolean true.', 'literal'), - helper('false', 'false', 'Boolean false.', 'literal'), - helper('null', 'null', 'Null value.', 'literal'), - helper('undefined', 'undefined', 'BSON Undefined (deprecated).', 'literal') - ] -} diff --git a/src/renderer/src/features/collection/QueryToolbar.tsx b/src/renderer/src/features/collection/QueryToolbar.tsx index 4a263e2..15a1f4c 100644 --- a/src/renderer/src/features/collection/QueryToolbar.tsx +++ b/src/renderer/src/features/collection/QueryToolbar.tsx @@ -1,14 +1,29 @@ import { type FormEvent, useEffect, useMemo, useState } from 'react' +import { useQuery } from '@tanstack/react-query' import { ArrowDownUp, Eye, Play, Wand2, X, XCircle } from 'lucide-react' import { Button } from '@/components/ui/button' import { Tooltip } from '@/components/ui/tooltip' import { cn } from '@/lib/utils' import { parseMongoQuery } from '@/lib/mongoQueryLang' -import { parseShellCommand, type ShellParseResult } from '@/lib/shellParser' +import { + isWriteOp, + parseShellCommand, + type ShellParseResult, + type ShellWriteRequest +} from '@/lib/shellParser' +import { + setDocumentFieldNames, + PIPELINE_COMPLETION_CONTEXT, + PROJECTION_COMPLETION_CONTEXT, + SORT_COMPLETION_CONTEXT, + type EditorCompletionContext +} from '@/lib/monacoCompletions' +import { api } from '@/lib/api' +import { queryKeys } from '@/lib/queryClient' import type { CollectionTab, QueryMode, QueryPatch } from '@/store/tabs' import type { DocumentEnvelope, UuidEncoding } from '@shared/types' import { ExportButton } from './ExportButton' -import { QueryEditor, setDocumentFieldNames } from './QueryEditor' +import { QueryEditor } from './QueryEditor' const EMPTY_OBJECT = '{}' const EMPTY_ARRAY = '[]' @@ -24,6 +39,7 @@ const MODES: ReadonlyArray<{ id: QueryMode; label: string }> = [ export function QueryToolbar({ tab, onApply, + onRunWrite, onCancel, loading, documents, @@ -35,6 +51,7 @@ export function QueryToolbar({ }: { tab: CollectionTab onApply: (patch: QueryPatch) => void + onRunWrite: (request: ShellWriteRequest) => void onCancel: () => void loading: boolean documents: DocumentEnvelope[] @@ -70,16 +87,38 @@ export function QueryToolbar({ setDocumentFieldNames(names) }, [documents]) + // Shell commands may address any collection of the database, so the + // completion needs the full list. The explorer already caches it. + const collectionsQuery = useQuery({ + queryKey: queryKeys.collections(tab.connectionId, tab.db), + queryFn: () => api.collections.list({ connectionId: tab.connectionId, db: tab.db }), + enabled: tab.mode === 'shell' + }) + const collectionNames = useMemo( + () => (collectionsQuery.data ?? []).map((entry) => entry.name), + [collectionsQuery.data] + ) + const shellCompletionContext = useMemo( + () => ({ kind: 'shell', coll: tab.coll, collections: collectionNames }), + [tab.coll, collectionNames] + ) + const filterStatus = useMemo(() => parseObjectStatus(filter), [filter]) const projectionStatus = useMemo(() => parseObjectStatus(projection), [projection]) const sortStatus = useMemo(() => parseObjectStatus(sort), [sort]) const pipelineStatus = useMemo(() => parsePipelineStatus(pipeline), [pipeline]) - const shellStatus = useMemo(() => parseShellStatus(shell, tab.coll), [shell, tab.coll]) + const shellStatus = useMemo(() => parseShellStatus(shell), [shell]) const simpleInvalid = filterStatus.kind === 'invalid' || projectionStatus.kind === 'invalid' || sortStatus.kind === 'invalid' + const modeEmpty = + tab.mode === 'aggregation' + ? pipelineStatus.kind === 'empty' + : tab.mode === 'shell' + ? shellStatus.kind === 'empty' + : false const modeInvalid = tab.mode === 'simple' ? simpleInvalid @@ -98,11 +137,20 @@ export function QueryToolbar({ const limitNum = Number.parseInt(limit, 10) const nextLimit = Number.isFinite(limitNum) && limitNum > 0 ? limitNum : 0 onApply({ filter, projection, sort, skip: 0, limit: nextLimit, runEpoch }) - } else if (tab.mode === 'aggregation') { + return + } + if (tab.mode === 'aggregation') { onApply({ pipeline, skip: 0, runEpoch }) - } else { - onApply({ shell, skip: 0, runEpoch }) + return } + if (shellStatus.kind === 'ok' && isWriteOp(shellStatus.parsed.op)) { + // Writes must not become part of a refetchable query key; commit the + // source and hand the command over for a single imperative run. + onApply({ shell, skip: 0 }) + onRunWrite({ command: shell, coll: shellStatus.parsed.coll, op: shellStatus.parsed.op }) + return + } + onApply({ shell, skip: 0, runEpoch }) } const setMode = (mode: QueryMode) => { @@ -147,8 +195,13 @@ export function QueryToolbar({ ) : ( - - @@ -204,6 +257,7 @@ export function QueryToolbar({ shell={shell} setShell={setShell} status={shellStatus} + completionContext={shellCompletionContext} onSubmit={() => apply()} /> )} @@ -306,6 +360,7 @@ function SimpleBody({ onSubmit={onSubmit} onFormat={() => formatObject(sortStatus, setSort)} status={sortStatus} + completionContext={SORT_COMPLETION_CONTEXT} minHeight={sharedRowH} onContentHeight={setSortContentH} /> @@ -318,6 +373,7 @@ function SimpleBody({ onSubmit={onSubmit} onFormat={() => formatObject(projectionStatus, setProjection)} status={projectionStatus} + completionContext={PROJECTION_COMPLETION_CONTEXT} minHeight={sharedRowH} onContentHeight={setProjContentH} /> @@ -347,6 +403,7 @@ function AggregationBody({ onSubmit={onSubmit} onFormat={onFormat} hasError={status.kind === 'invalid'} + completionContext={PIPELINE_COMPLETION_CONTEXT} minHeight={120} maxHeight={400} placeholder={'[\n { $match: { … } },\n { $group: { _id: "$type", n: { $sum: 1 } } }\n]'} @@ -366,12 +423,14 @@ function ShellBody({ shell, setShell, status, + completionContext, onSubmit }: { coll: string shell: string setShell: (next: string) => void status: ShellStatus + completionContext: EditorCompletionContext onSubmit: () => void }) { return ( @@ -381,6 +440,7 @@ function ShellBody({ onChange={setShell} onSubmit={onSubmit} hasError={status.kind === 'invalid'} + completionContext={completionContext} minHeight={60} maxHeight={240} placeholder={`db.${coll}.find({ … }).sort({ … }).limit(50)`} @@ -390,6 +450,16 @@ function ShellBody({ ) } +function runHint(mode: QueryMode, coll: string, empty: boolean, invalid: boolean): string { + if (empty) { + return mode === 'shell' + ? `Type a command such as db.${coll}.find({}) to run it` + : 'Add at least one pipeline stage to run it' + } + if (invalid) return 'Fix the highlighted input to run this query' + return 'Run · ⌘/Ctrl-Enter' +} + function LimitInput({ value, onChange }: { value: string; onChange: (next: string) => void }) { return ( @@ -419,6 +489,7 @@ function OptionRow({ onSubmit, onFormat, status, + completionContext, minHeight, onContentHeight }: { @@ -430,6 +501,7 @@ function OptionRow({ onSubmit: () => void onFormat: () => void status: ObjectStatus + completionContext: EditorCompletionContext minHeight: number onContentHeight: (px: number) => void }) { @@ -446,6 +518,7 @@ function OptionRow({ onSubmit={onSubmit} onFormat={onFormat} hasError={status.kind === 'invalid'} + completionContext={completionContext} minHeight={minHeight} maxHeight={120} placeholder={placeholder} @@ -536,16 +609,10 @@ type ShellStatus = | { kind: 'ok'; parsed: ShellParseResult & { ok: true } } | { kind: 'invalid'; error: string } -function parseShellStatus(value: string, expectedColl: string): ShellStatus { +function parseShellStatus(value: string): ShellStatus { const trimmed = value.trim() if (trimmed.length === 0) return { kind: 'empty' } const parsed = parseShellCommand(trimmed) if (!parsed.ok) return { kind: 'invalid', error: parsed.error } - if (parsed.coll !== expectedColl) { - return { - kind: 'invalid', - error: `Collection mismatch: this tab is "${expectedColl}", command targets "${parsed.coll}"` - } - } return { kind: 'ok', parsed } } diff --git a/src/renderer/src/features/collection/StatusBar.tsx b/src/renderer/src/features/collection/StatusBar.tsx index b61367e..d3475f5 100644 --- a/src/renderer/src/features/collection/StatusBar.tsx +++ b/src/renderer/src/features/collection/StatusBar.tsx @@ -10,6 +10,7 @@ export function StatusBar({ skip, pageDocs, tookMs, + coll, onJump }: { loading: boolean @@ -20,6 +21,8 @@ export function StatusBar({ skip: number pageDocs: number tookMs: number | undefined + /** Set when the result comes from another collection than the tab's. */ + coll: string | null onJump: (skip: number) => void }) { const hasLimit = pageSize > 0 @@ -37,6 +40,11 @@ export function StatusBar({ return (
+ {coll && ( + + {coll} + + )} {loading ? ( diff --git a/src/renderer/src/lib/api.ts b/src/renderer/src/lib/api.ts index 249c3b3..f5027ad 100644 --- a/src/renderer/src/lib/api.ts +++ b/src/renderer/src/lib/api.ts @@ -14,6 +14,8 @@ import type { CreateUserPayload, DatabaseInfo, DatabaseUser, + DeleteByFilterRequest, + DeleteByFilterResponse, DeleteManyRequest, DeleteManyResponse, DeleteOneRequest, @@ -28,9 +30,13 @@ import type { InsertOneRequest, InsertOneResponse, RenameCollectionPayload, + ReplaceByFilterRequest, + ReplaceByFilterResponse, ReplaceOneRequest, ReplaceOneResponse, ServerStats, + UpdateByFilterRequest, + UpdateByFilterResponse, UpdateUserPayload } from '@shared/types' @@ -109,7 +115,13 @@ export const api = { deleteOne: (request: DeleteOneRequest): Promise => unwrap(window.api.query.deleteOne(request)), deleteMany: (request: DeleteManyRequest): Promise => - unwrap(window.api.query.deleteMany(request)) + unwrap(window.api.query.deleteMany(request)), + updateByFilter: (request: UpdateByFilterRequest): Promise => + unwrap(window.api.query.updateByFilter(request)), + deleteByFilter: (request: DeleteByFilterRequest): Promise => + unwrap(window.api.query.deleteByFilter(request)), + replaceByFilter: (request: ReplaceByFilterRequest): Promise => + unwrap(window.api.query.replaceByFilter(request)) }, users: { list: (payload: { connectionId: string; db: string }): Promise => diff --git a/src/renderer/src/lib/monacoCompletions.ts b/src/renderer/src/lib/monacoCompletions.ts new file mode 100644 index 0000000..a58350f --- /dev/null +++ b/src/renderer/src/lib/monacoCompletions.ts @@ -0,0 +1,176 @@ +import * as monaco from 'monaco-editor' +import { + describeSymbol, + resolveCompletions, + resolveSignature, + QUERY_COMPLETION_CONTEXT, + type CompletionGroup, + type CompletionItemData, + type EditorCompletionContext, + type SuggestionKind +} from './mongoCompletionModel' + +export { + QUERY_COMPLETION_CONTEXT, + SORT_COMPLETION_CONTEXT, + PROJECTION_COMPLETION_CONTEXT, + PIPELINE_COMPLETION_CONTEXT, + type EditorCompletionContext +} from './mongoCompletionModel' + +const MONACO_KINDS: Record = { + operator: monaco.languages.CompletionItemKind.Function, + stage: monaco.languages.CompletionItemKind.Module, + expression: monaco.languages.CompletionItemKind.Function, + update: monaco.languages.CompletionItemKind.Event, + ejson: monaco.languages.CompletionItemKind.Value, + value: monaco.languages.CompletionItemKind.Constant, + field: monaco.languages.CompletionItemKind.Field, + fieldPath: monaco.languages.CompletionItemKind.Variable, + helper: monaco.languages.CompletionItemKind.Constructor, + literal: monaco.languages.CompletionItemKind.Keyword, + method: monaco.languages.CompletionItemKind.Method, + collection: monaco.languages.CompletionItemKind.Class, + database: monaco.languages.CompletionItemKind.Variable, + command: monaco.languages.CompletionItemKind.Snippet +} + +const RETRIGGER_COMMAND = { id: 'editor.action.triggerSuggest', title: 'Suggest' } + +const contextByModel = new Map() + +let documentFieldNames: string[] = [] +let providerRegistered = false + +export function setDocumentFieldNames(names: Iterable): void { + documentFieldNames = Array.from(new Set(names)).sort((a, b) => a.localeCompare(b)) +} + +export function setEditorCompletionContext(uri: string, context: EditorCompletionContext): void { + contextByModel.set(uri, context) +} + +export function clearEditorCompletionContext(uri: string): void { + contextByModel.delete(uri) +} + +export function registerMongoLanguageProviders(): void { + if (providerRegistered) return + providerRegistered = true + + monaco.languages.registerCompletionItemProvider('mongo-shell', { + triggerCharacters: ['$', '"', '.', '{', '[', '(', ',', ':', ' '], + provideCompletionItems(model, position) { + const line = model.getLineContent(position.lineNumber) + const groups = resolveCompletions({ + context: contextByModel.get(model.uri.toString()) ?? QUERY_COMPLETION_CONTEXT, + textUpToCursor: textUpToCursor(model, position), + lineUpToCursor: line.slice(0, position.column - 1), + charAfterCursor: line.charAt(position.column - 1), + fieldNames: documentFieldNames + }) + return { suggestions: groups.flatMap((group) => toItems(group, position)) } + } + }) + + monaco.languages.registerSignatureHelpProvider('mongo-shell', { + signatureHelpTriggerCharacters: ['(', ','], + signatureHelpRetriggerCharacters: [','], + provideSignatureHelp(model, position) { + const info = resolveSignature(textUpToCursor(model, position)) + if (!info) return null + return { + value: { + signatures: [ + { + label: info.label, + documentation: { value: info.doc }, + parameters: info.parameters.map((parameter) => ({ + label: parameter.label, + documentation: { value: parameter.doc } + })) + } + ], + activeSignature: 0, + activeParameter: info.activeParameter + }, + dispose: () => undefined + } + } + }) + + monaco.languages.registerHoverProvider('mongo-shell', { + provideHover(model, position) { + const line = model.getLineContent(position.lineNumber) + const token = tokenAt(line, position.column) + if (!token) return null + const description = describeSymbol(token.text) + if (!description) return null + const sections = description.entries.map((entry) => '_' + entry.detail + '_\n\n' + entry.doc) + return { + range: new monaco.Range( + position.lineNumber, + token.startColumn, + position.lineNumber, + token.endColumn + ), + contents: [{ value: '**' + description.label + '**' }, { value: sections.join('\n\n') }] + } + } + }) +} + +function textUpToCursor(model: monaco.editor.ITextModel, position: monaco.Position): string { + return model.getValueInRange({ + startLineNumber: 1, + startColumn: 1, + endLineNumber: position.lineNumber, + endColumn: position.column + }) +} + +function tokenAt( + line: string, + column: number +): { text: string; startColumn: number; endColumn: number } | null { + const pattern = /[$A-Za-z_][\w$]*/g + const offset = column - 1 + let match = pattern.exec(line) + while (match) { + const start = match.index + const end = start + match[0].length + if (offset >= start && offset <= end) { + return { text: match[0], startColumn: start + 1, endColumn: end + 1 } + } + match = pattern.exec(line) + } + return null +} + +function toItems( + group: CompletionGroup, + position: monaco.Position +): monaco.languages.CompletionItem[] { + const range = new monaco.Range( + position.lineNumber, + position.column - group.prefixLength, + position.lineNumber, + group.consumeTrailingQuote ? position.column + 1 : position.column + ) + return group.items.map((item) => toItem(item, range)) +} + +function toItem(item: CompletionItemData, range: monaco.IRange): monaco.languages.CompletionItem { + return { + label: item.label, + kind: MONACO_KINDS[item.kind], + insertText: item.insertText, + insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, + filterText: item.filterText, + documentation: { value: item.doc }, + detail: item.detail, + sortText: item.sortText, + range, + ...(item.retrigger ? { command: RETRIGGER_COMMAND } : {}) + } +} diff --git a/src/renderer/src/lib/monacoSetup.ts b/src/renderer/src/lib/monacoSetup.ts index b6189e6..57720ef 100644 --- a/src/renderer/src/lib/monacoSetup.ts +++ b/src/renderer/src/lib/monacoSetup.ts @@ -11,6 +11,7 @@ import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker' import JsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker' import * as monaco from 'monaco-editor' import { loader } from '@monaco-editor/react' +import { registerMongoLanguageProviders } from './monacoCompletions' self.MonacoEnvironment = { getWorker(_workerId, label) { @@ -224,6 +225,8 @@ monaco.editor.defineTheme('mongobench-dark', { } }) +registerMongoLanguageProviders() + loader.config({ monaco }) void loader.init() diff --git a/src/renderer/src/lib/mongoCompletionModel.test.ts b/src/renderer/src/lib/mongoCompletionModel.test.ts new file mode 100644 index 0000000..bafd4ac --- /dev/null +++ b/src/renderer/src/lib/mongoCompletionModel.test.ts @@ -0,0 +1,407 @@ +import { describe, expect, it } from 'vitest' +import { + describeSymbol, + resolveCompletions, + resolveSignature, + PIPELINE_COMPLETION_CONTEXT, + PROJECTION_COMPLETION_CONTEXT, + QUERY_COMPLETION_CONTEXT, + SORT_COMPLETION_CONTEXT, + type CompletionGroup, + type CompletionItemData, + type EditorCompletionContext +} from './mongoCompletionModel' +import { parseMongoQuery } from './mongoQueryLang' +import { parseShellCommand } from './shellParser' + +const SHELL_CONTEXT: EditorCompletionContext = { + kind: 'shell', + coll: 'users', + collections: ['orders', 'users'] +} + +function complete( + text: string, + context: EditorCompletionContext, + options?: { fieldNames?: string[]; charAfterCursor?: string } +): CompletionGroup[] { + const lines = text.split('\n') + return resolveCompletions({ + context, + textUpToCursor: text, + lineUpToCursor: lines[lines.length - 1] ?? '', + charAfterCursor: options?.charAfterCursor ?? '', + fieldNames: options?.fieldNames ?? [] + }) +} + +function labels(groups: CompletionGroup[]): string[] { + return groups.flatMap((group) => group.items.map((item) => item.label)) +} + +function insertTextOf(groups: CompletionGroup[], label: string): string | undefined { + return groups.flatMap((group) => group.items).find((item) => item.label === label)?.insertText +} + +describe('shell completions', () => { + it('offers db on an empty command', () => { + const groups = complete('', SHELL_CONTEXT) + expect(labels(groups)).toContain('db') + expect(groups[0]?.prefixLength).toBe(0) + }) + + it('replaces the typed prefix of db', () => { + const groups = complete('d', SHELL_CONTEXT) + expect(labels(groups)).toContain('db') + expect(groups[0]?.prefixLength).toBe(1) + }) + + it('offers every collection of the database after db.', () => { + const groups = complete('db.', SHELL_CONTEXT) + expect(labels(groups)).toEqual(['orders', 'users']) + expect(insertTextOf(groups, 'users')).toBe('users.') + }) + + it('sorts the tab collection to the top', () => { + const groups = complete('db.', SHELL_CONTEXT) + const items = groups.flatMap((entry) => entry.items) + expect(items.find((item) => item.label === 'users')?.sortText).toBe('0_users') + expect(items.find((item) => item.label === 'orders')?.sortText).toBe('1_orders') + }) + + it('keeps the tab collection even when the list has not loaded', () => { + const groups = complete('db.', { kind: 'shell', coll: 'users', collections: [] }) + expect(labels(groups)).toEqual(['users']) + }) + + it('offers read and write methods after db.users.', () => { + const groups = complete('db.users.', SHELL_CONTEXT) + expect(labels(groups)).toEqual([ + 'find', + 'findOne', + 'aggregate', + 'countDocuments', + 'count', + 'insertOne', + 'insertMany', + 'updateOne', + 'updateMany', + 'replaceOne', + 'deleteOne', + 'deleteMany' + ]) + }) + + it('offers no chained methods after a write', () => { + expect(labels(complete('db.users.deleteMany({}).', SHELL_CONTEXT))).toEqual([]) + }) + + it('offers update operators in the update argument', () => { + const groups = complete('db.users.updateOne({ a: 1 }, { $', SHELL_CONTEXT) + expect(labels(groups)).toContain('$set') + expect(labels(groups)).toContain('$inc') + expect(labels(groups)).not.toContain('$gte') + }) + + it('offers query operators in the update filter argument', () => { + const groups = complete('db.users.updateOne({ a: { $', SHELL_CONTEXT) + expect(labels(groups)).toContain('$gte') + expect(labels(groups)).not.toContain('$inc') + }) + + it('offers sort directions inside .sort()', () => { + const groups = complete('db.users.find({}).sort({ createdAt: ', SHELL_CONTEXT) + expect(labels(groups)).toEqual(['1', '-1']) + }) + + it('offers projection values in the second find argument', () => { + const groups = complete('db.users.find({}, { name: ', SHELL_CONTEXT) + expect(labels(groups)).toEqual(['1', '0']) + }) + + it('offers no values in a filter position', () => { + const groups = complete('db.users.find({ age: ', SHELL_CONTEXT) + expect(labels(groups)).not.toContain('-1') + }) + + it('keeps the typed method prefix', () => { + const groups = complete('db.users.fi', SHELL_CONTEXT) + expect(groups[0]?.prefixLength).toBe(2) + }) + + it('offers cursor methods after find(...)', () => { + const groups = complete('db.users.find({}).', SHELL_CONTEXT) + expect(labels(groups)).toEqual(['sort', 'skip', 'limit', 'toArray', 'pretty']) + }) + + it('offers chained methods after a chained call', () => { + const groups = complete('db.users.find({}).sort({ a: 1 }).', SHELL_CONTEXT) + expect(labels(groups)).toContain('limit') + }) + + it('restricts the chain of non cursor methods', () => { + const groups = complete('db.users.aggregate([]).', SHELL_CONTEXT) + expect(labels(groups)).toEqual(['toArray', 'pretty']) + }) + + it('offers query operators inside find arguments', () => { + const groups = complete('db.users.find({ $', SHELL_CONTEXT) + expect(labels(groups)).toContain('$gt') + expect(labels(groups)).not.toContain('$group') + }) + + it('offers pipeline stages inside aggregate arguments', () => { + const groups = complete('db.users.aggregate([{ $', SHELL_CONTEXT) + expect(labels(groups)).toContain('$match') + expect(labels(groups)).toContain('$group') + }) + + it('does not offer structure suggestions inside arguments', () => { + const groups = complete('db.users.find({ na', SHELL_CONTEXT, { fieldNames: ['name'] }) + expect(labels(groups)).toContain('name') + expect(labels(groups)).not.toContain('db') + }) +}) + +describe('aggregation completions', () => { + it('offers stages at the stage key position', () => { + const groups = complete('[{ $', PIPELINE_COMPLETION_CONTEXT) + expect(labels(groups)).toContain('$match') + expect(insertTextOf(groups, '$match')).toBe('"$match": { $0 }') + }) + + it('wraps a stage in an object when typed directly inside the array', () => { + const groups = complete('[$', PIPELINE_COMPLETION_CONTEXT) + expect(insertTextOf(groups, '$group')).toMatch(/^\{ "\$group": /) + expect(insertTextOf(groups, '$group')).toMatch(/ \}$/) + }) + + it('offers query operators inside $match', () => { + const groups = complete('[{ $match: { age: { $', PIPELINE_COMPLETION_CONTEXT) + expect(labels(groups)).toContain('$gte') + expect(insertTextOf(groups, '$gte')).toBe('"$gte": ${1:value}') + expect(labels(groups)).not.toContain('$toUpper') + }) + + it('offers aggregation expressions inside $group', () => { + const groups = complete('[{ $group: { _id: { $', PIPELINE_COMPLETION_CONTEXT) + expect(labels(groups)).toContain('$toUpper') + expect(labels(groups)).toContain('$sum') + }) + + it('offers aggregation expressions inside $expr', () => { + const groups = complete('[{ $match: { $expr: { $', PIPELINE_COMPLETION_CONTEXT) + expect(insertTextOf(groups, '$eq')).toBe('"$eq": [${1:expression}, ${2:expression}]') + }) + + it('offers field paths in a value position', () => { + const groups = complete('[{ $group: { _id: "$', PIPELINE_COMPLETION_CONTEXT, { + fieldNames: ['status'] + }) + expect(insertTextOf(groups, '$status')).toBe('"$status"') + }) +}) + +describe('simple mode roles', () => { + it('offers sort directions in the sort editor', () => { + const groups = complete('{ createdAt: ', SORT_COMPLETION_CONTEXT) + expect(labels(groups)).toEqual(['1', '-1']) + }) + + it('offers include and exclude in the projection editor', () => { + const groups = complete('{ name: ', PROJECTION_COMPLETION_CONTEXT) + expect(labels(groups)).toEqual(['1', '0']) + }) + + it('leaves the filter editor to operators and helpers', () => { + const groups = complete('{ age: ', QUERY_COMPLETION_CONTEXT) + expect(labels(groups)).not.toContain('-1') + }) +}) + +describe('quoting', () => { + it('inserts operators without a stray trailing quote', () => { + const groups = complete('{ $', QUERY_COMPLETION_CONTEXT) + expect(insertTextOf(groups, '$eq')).toBe('"$eq": ${1:value}') + expect(insertTextOf(groups, '$oid')).toBe('"$oid": "${1:507f1f77bcf86cd799439011}"') + }) + + it('replaces an auto closed quote around the operator', () => { + const groups = complete('{ "$g', QUERY_COMPLETION_CONTEXT, { charAfterCursor: '"' }) + expect(groups[0]?.prefixLength).toBe(3) + expect(groups[0]?.consumeTrailingQuote).toBe(true) + expect(groups[0]?.items.find((item) => item.label === '$gt')?.filterText).toBe('"$gt') + }) + + it('keeps the trailing quote when the cursor is not followed by one', () => { + const groups = complete('{ "$g', QUERY_COMPLETION_CONTEXT, { charAfterCursor: '}' }) + expect(groups[0]?.consumeTrailingQuote).toBe(false) + }) + + it('completes document fields at a key position', () => { + const groups = complete('{ ', QUERY_COMPLETION_CONTEXT, { fieldNames: ['createdAt'] }) + expect(insertTextOf(groups, 'createdAt')).toBe('"createdAt"') + expect(groups[0]?.prefixLength).toBe(0) + }) + + it('completes shell helpers in a value position', () => { + const groups = complete('{ _id: Obj', QUERY_COMPLETION_CONTEXT) + expect(labels(groups)).toContain('ObjectId') + expect(groups[groups.length - 1]?.prefixLength).toBe(3) + }) +}) + +describe('signature help', () => { + it('describes the call the cursor sits in', () => { + const info = resolveSignature('db.users.find({ a: 1 }, ') + expect(info?.label).toBe('find(filter, projection)') + expect(info?.activeParameter).toBe(1) + }) + + it('tracks the argument index of updates', () => { + expect(resolveSignature('db.users.updateOne(')?.activeParameter).toBe(0) + expect(resolveSignature('db.users.updateOne({}, ')?.activeParameter).toBe(1) + expect(resolveSignature('db.users.updateOne({}, {}, ')?.activeParameter).toBe(2) + }) + + it('ignores commas inside nested values', () => { + const info = resolveSignature('db.users.find({ a: [1, 2, 3] ') + expect(info?.activeParameter).toBe(0) + }) + + it('describes helper calls too', () => { + expect(resolveSignature('{ _id: ObjectId(')?.label).toBe('ObjectId(hex)') + }) + + it('returns nothing outside a call', () => { + expect(resolveSignature('db.users.find({}) ')).toBeNull() + expect(resolveSignature('[{ $match: { ')).toBeNull() + }) +}) + +describe('hover documentation', () => { + it('documents query operators', () => { + expect(describeSymbol('$gte')?.entries[0]?.detail).toBe('MongoDB query operator') + }) + + it('collects every meaning of an overloaded operator', () => { + const details = describeSymbol('$eq')?.entries.map((entry) => entry.detail) + expect(details).toContain('MongoDB query operator') + expect(details).toContain('Aggregation expression') + }) + + it('documents stages, update operators, helpers and methods', () => { + expect(describeSymbol('$lookup')?.entries[0]?.detail).toBe('Aggregation stage') + expect(describeSymbol('$inc')?.entries[0]?.detail).toBe('Update operator') + expect(describeSymbol('ObjectId')?.entries[0]?.detail).toBe('mongo shell helper') + expect(describeSymbol('deleteMany')?.entries[0]?.detail).toBe('Collection method') + }) + + it('returns nothing for unknown words', () => { + expect(describeSymbol('somethingElse')).toBeNull() + }) +}) + +describe('inserted snippets stay parseable', () => { + const itemsOf = (groups: CompletionGroup[]): CompletionItemData[] => + groups.flatMap((group) => group.items) + + const expectQueryParses = (source: string): void => { + const result = parseMongoQuery(source) + expect(result.ok, `${source}: ${result.ok ? '' : result.error}`).toBe(true) + } + + const expectShellParses = (source: string): void => { + const result = parseShellCommand(source) + expect(result.ok, `${source}: ${result.ok ? '' : result.error}`).toBe(true) + } + + it('accepts every stage as a pipeline entry', () => { + for (const item of itemsOf(complete('[{ $', PIPELINE_COMPLETION_CONTEXT))) { + expectQueryParses('[{ ' + fillSnippet(item.insertText) + ' }]') + } + }) + + it('accepts every stage typed directly inside the array', () => { + for (const item of itemsOf(complete('[$', PIPELINE_COMPLETION_CONTEXT))) { + expectQueryParses('[' + fillSnippet(item.insertText) + ']') + } + }) + + it('accepts every query operator', () => { + for (const item of itemsOf(complete('{ $', QUERY_COMPLETION_CONTEXT))) { + expectQueryParses('{ ' + fillSnippet(item.insertText) + ' }') + } + }) + + it('accepts every aggregation expression', () => { + for (const item of itemsOf(complete('[{ $group: { _id: { $', PIPELINE_COMPLETION_CONTEXT))) { + expectQueryParses('{ ' + fillSnippet(item.insertText) + ' }') + } + }) + + it('accepts every collection method', () => { + for (const item of itemsOf(complete('db.users.', SHELL_CONTEXT))) { + expectShellParses('db.users.' + fillSnippet(item.insertText)) + } + }) + + it('accepts every chained cursor method', () => { + for (const item of itemsOf(complete('db.users.find({}).', SHELL_CONTEXT))) { + expectShellParses('db.users.find({}).' + fillSnippet(item.insertText)) + } + }) + + it('accepts the root level command snippets', () => { + for (const item of itemsOf(complete('', SHELL_CONTEXT))) { + if (item.kind !== 'command') continue + expectShellParses(fillSnippet(item.insertText)) + } + }) +}) + +function fillSnippet(snippet: string): string { + let out = '' + let i = 0 + while (i < snippet.length) { + const char = snippet.charAt(i) + if (char !== '$') { + out += char + i++ + continue + } + const next = snippet.charAt(i + 1) + if (next === '{') { + const end = matchingBrace(snippet, i + 1) + if (end < 0) { + out += char + i++ + continue + } + out += '1' + i = end + 1 + continue + } + if (next >= '0' && next <= '9') { + i += 2 + while (snippet.charAt(i) >= '0' && snippet.charAt(i) <= '9') i++ + continue + } + out += char + i++ + } + return out +} + +function matchingBrace(text: string, open: number): number { + let depth = 0 + for (let i = open; i < text.length; i++) { + const char = text.charAt(i) + if (char === '{') depth++ + else if (char === '}') { + depth-- + if (depth === 0) return i + } + } + return -1 +} diff --git a/src/renderer/src/lib/mongoCompletionModel.ts b/src/renderer/src/lib/mongoCompletionModel.ts new file mode 100644 index 0000000..e5fbec0 --- /dev/null +++ b/src/renderer/src/lib/mongoCompletionModel.ts @@ -0,0 +1,1115 @@ +export type QueryRole = 'filter' | 'sort' | 'projection' + +export type EditorCompletionContext = + | { kind: 'query'; role: QueryRole } + | { kind: 'pipeline' } + | { kind: 'shell'; coll: string; collections: readonly string[] } + +export const QUERY_COMPLETION_CONTEXT: EditorCompletionContext = { kind: 'query', role: 'filter' } +export const SORT_COMPLETION_CONTEXT: EditorCompletionContext = { kind: 'query', role: 'sort' } +export const PROJECTION_COMPLETION_CONTEXT: EditorCompletionContext = { + kind: 'query', + role: 'projection' +} +export const PIPELINE_COMPLETION_CONTEXT: EditorCompletionContext = { kind: 'pipeline' } + +export type SuggestionKind = + | 'operator' + | 'stage' + | 'expression' + | 'update' + | 'ejson' + | 'field' + | 'fieldPath' + | 'helper' + | 'literal' + | 'value' + | 'method' + | 'collection' + | 'database' + | 'command' + +export type CompletionItemData = { + label: string + insertText: string + filterText: string + doc: string + detail: string + kind: SuggestionKind + sortText: string + retrigger: boolean +} + +export type CompletionGroup = { + prefixLength: number + consumeTrailingQuote: boolean + items: CompletionItemData[] +} + +export type CompletionRequest = { + context: EditorCompletionContext + textUpToCursor: string + lineUpToCursor: string + charAfterCursor: string + fieldNames: readonly string[] +} + +type Signature = readonly [label: string, value: string, doc: string] + +type BracketEntry = { char: '{' | '[' | '('; index: number } + +type BracketScan = { stack: BracketEntry[]; inString: boolean } + +type StagePosition = 'array' | 'object' + +export function resolveCompletions(request: CompletionRequest): CompletionGroup[] { + const { context, textUpToCursor, lineUpToCursor, charAfterCursor, fieldNames } = request + const scan = scanBrackets(textUpToCursor) + const call = enclosingCall(textUpToCursor, scan) + + if (context.kind === 'shell') { + const structural = shellStructureGroup(context, textUpToCursor, scan) + if (structural) return [structural] + } + + const valueMatch = /:\s*(-?\d*)$/.exec(lineUpToCursor) + if (valueMatch) { + const values = valueSignatures(context, call) + if (values) { + return [ + { + prefixLength: (valueMatch[1] ?? '').length, + consumeTrailingQuote: false, + items: plainItems(values, 'value', 'Value') + } + ] + } + } + + const operatorMatch = /"?\$[A-Za-z0-9]*$/.exec(lineUpToCursor) + if (operatorMatch) { + const quoted = operatorMatch[0].startsWith('"') + const group: CompletionGroup = { + prefixLength: operatorMatch[0].length, + consumeTrailingQuote: quoted && charAfterCursor === '"', + items: [] + } + const stagePosition = stagePositionOf(context, textUpToCursor, scan) + if (stagePosition) { + group.items = keyItems(AGGREGATION_STAGES, 'stage', 'Aggregation stage', quoted, { + wrapInObject: stagePosition === 'array' + }) + return [group] + } + if (isUpdatePosition(context, call)) { + group.items = keyItems(UPDATE_OPERATORS, 'update', 'Update operator', quoted) + return [group] + } + group.items = operatorItems(context, textUpToCursor, scan, quoted) + if (isAggregationSource(context, textUpToCursor)) { + group.items.push(...fieldPathItems(fieldNames, quoted)) + } + return [group] + } + + const groups: CompletionGroup[] = [] + + const keyMatch = /(?:^|[{,])\s*("?)([A-Za-z_$][\w.]*)?$/.exec(lineUpToCursor) + if (keyMatch && fieldNames.length > 0) { + const quoted = keyMatch[1] === '"' + const partial = keyMatch[2] ?? '' + groups.push({ + prefixLength: partial.length + (quoted ? 1 : 0), + consumeTrailingQuote: quoted && charAfterCursor === '"', + items: fieldNames.map((name) => ({ + label: name, + insertText: '"' + name + '"', + filterText: (quoted ? '"' : '') + name, + doc: 'Top level field of the documents in this collection.', + detail: 'Document field', + kind: 'field' as const, + sortText: '0_' + name, + retrigger: false + })) + }) + } + + const helperMatch = /(?:^|[\s:,[(])([A-Za-z][A-Za-z0-9]*)$/.exec(lineUpToCursor) + if (helperMatch) { + groups.push({ + prefixLength: (helperMatch[1] ?? '').length, + consumeTrailingQuote: false, + items: shellHelperItems() + }) + } + + return groups +} + +function shellStructureGroup( + context: { coll: string; collections: readonly string[] }, + text: string, + scan: BracketScan +): CompletionGroup | null { + if (scan.inString || scan.stack.length > 0) return null + + const chainMatch = /\)\s*\.\s*([A-Za-z_$][\w$]*)?$/.exec(text) + if (chainMatch) { + return group(chainMatch[1], chainMethodItems(headMethodOf(text))) + } + + const methodMatch = /^\s*db\s*\.\s*[A-Za-z_$][\w$]*\s*\.\s*([A-Za-z_$][\w$]*)?$/.exec(text) + if (methodMatch) { + return group(methodMatch[1], methodItems(COLLECTION_METHODS)) + } + + const collectionMatch = /^\s*db\s*\.\s*([A-Za-z_$][\w$]*)?$/.exec(text) + if (collectionMatch) { + return group(collectionMatch[1], collectionItems(context.coll, context.collections)) + } + + const rootMatch = /^\s*([A-Za-z_$][\w$]*)?$/.exec(text) + if (rootMatch) { + return group(rootMatch[1], rootItems(context.coll)) + } + + return null +} + +function collectionItems(coll: string, collections: readonly string[]): CompletionItemData[] { + const names = collections.includes(coll) ? collections : [coll, ...collections] + return names.map((name) => ({ + label: name, + insertText: name + '.', + filterText: name, + doc: + name === coll + ? 'Collection of the current tab.' + : 'Another collection of this database. Results replace the tab content.', + detail: 'Collection', + kind: 'collection' as const, + sortText: (name === coll ? '0_' : '1_') + name, + retrigger: true + })) +} + +function group(prefix: string | undefined, items: CompletionItemData[]): CompletionGroup { + return { prefixLength: (prefix ?? '').length, consumeTrailingQuote: false, items } +} + +function headMethodOf(text: string): string | null { + const head = /^\s*db\s*\.\s*[A-Za-z_$][\w$]*\s*\.\s*([A-Za-z_$][\w$]*)\s*\(/.exec(text) + return head?.[1] ?? null +} + +function rootItems(coll: string): CompletionItemData[] { + return [ + { + label: 'db', + insertText: 'db.', + filterText: 'db', + doc: 'Shell handle for the current database.', + detail: 'mongo shell', + kind: 'database', + sortText: '0_db', + retrigger: true + }, + { + label: 'db.' + coll + '.find', + insertText: 'db.' + coll + '.find({ $0 })', + filterText: 'db.' + coll + '.find', + doc: 'Query documents in this collection.', + detail: 'mongo shell command', + kind: 'command', + sortText: '1_find', + retrigger: false + }, + { + label: 'db.' + coll + '.aggregate', + insertText: 'db.' + coll + '.aggregate([\n\t{ $0 }\n])', + filterText: 'db.' + coll + '.aggregate', + doc: 'Run an aggregation pipeline on this collection.', + detail: 'mongo shell command', + kind: 'command', + sortText: '1_aggregate', + retrigger: false + } + ] +} + +function methodItems(signatures: readonly Signature[]): CompletionItemData[] { + return signatures.map(([label, insertText, doc]) => ({ + label, + insertText, + filterText: label, + doc, + detail: 'mongo shell method', + kind: 'method' as const, + sortText: label, + retrigger: false + })) +} + +function chainMethodItems(headMethod: string | null): CompletionItemData[] { + if (headMethod === 'find') return methodItems(CURSOR_METHODS) + if (headMethod !== null && WRITE_METHOD_NAMES.has(headMethod)) return [] + return methodItems(NO_ARGUMENT_METHODS) +} + +function keyItems( + signatures: readonly Signature[], + kind: SuggestionKind, + detail: string, + quoted: boolean, + options?: { wrapInObject?: boolean } +): CompletionItemData[] { + return signatures.map(([label, value, doc]) => { + const body = '"' + label + '": ' + value + return { + label, + insertText: options?.wrapInObject ? '{ ' + body + ' }' : body, + filterText: (quoted ? '"' : '') + label, + doc, + detail, + kind, + sortText: label, + retrigger: false + } + }) +} + +function operatorItems( + context: EditorCompletionContext, + text: string, + scan: BracketScan, + quoted: boolean +): CompletionItemData[] { + const ejson = keyItems(ejsonWrappers(), 'ejson', 'EJSON / BSON type', quoted) + if (!usesAggregationExpressions(context, text, scan)) { + return [...keyItems(QUERY_OPERATORS, 'operator', 'MongoDB query operator', quoted), ...ejson] + } + return [ + ...keyItems(AGGREGATION_EXPRESSIONS, 'expression', 'Aggregation expression', quoted), + ...ejson + ] +} + +function fieldPathItems(fieldNames: readonly string[], quoted: boolean): CompletionItemData[] { + return fieldNames.map((name) => ({ + label: '$' + name, + insertText: '"$' + name + '"', + filterText: (quoted ? '"' : '') + '$' + name, + doc: 'References the `' + name + '` field of the current document.', + detail: 'Field path', + kind: 'fieldPath' as const, + sortText: '0_' + name, + retrigger: false + })) +} + +function shellHelperItems(): CompletionItemData[] { + return [ + ...shellHelpers().map(([label, insertText, doc]) => ({ + label, + insertText, + filterText: label, + doc, + detail: 'mongo shell helper', + kind: 'helper' as const, + sortText: label, + retrigger: false + })), + ...LITERALS.map(([label, insertText, doc]) => ({ + label, + insertText, + filterText: label, + doc, + detail: 'literal', + kind: 'literal' as const, + sortText: label, + retrigger: false + })) + ] +} + +export type SignatureInfo = { + label: string + doc: string + parameters: { label: string; doc: string }[] + activeParameter: number +} + +/** Parameter hints for the call the cursor currently sits in. */ +export function resolveSignature(textUpToCursor: string): SignatureInfo | null { + const scan = scanBrackets(textUpToCursor) + if (scan.inString) return null + const call = enclosingCall(textUpToCursor, scan) + if (!call) return null + const definition = CALL_SIGNATURES[call.name] + if (!definition) return null + return { + label: call.name + '(' + definition.parameters.map(([name]) => name).join(', ') + ')', + doc: definition.doc, + parameters: definition.parameters.map(([name, doc]) => ({ label: name, doc })), + activeParameter: Math.min(call.argIndex, Math.max(0, definition.parameters.length - 1)) + } +} + +export type SymbolDescription = { + label: string + entries: { detail: string; doc: string }[] +} + +/** Documentation shown when hovering an operator, helper or method. */ +export function describeSymbol(label: string): SymbolDescription | null { + const entries = symbolIndex().get(label) + return entries ? { label, entries } : null +} + +let symbolIndexCache: Map | null = null + +function symbolIndex(): Map { + if (symbolIndexCache) return symbolIndexCache + const index = new Map() + const add = (signatures: readonly Signature[], detail: string): void => { + for (const [label, , doc] of signatures) { + const existing = index.get(label) + if (existing) existing.push({ detail, doc }) + else index.set(label, [{ detail, doc }]) + } + } + add(QUERY_OPERATORS, 'MongoDB query operator') + add(AGGREGATION_STAGES, 'Aggregation stage') + add(AGGREGATION_EXPRESSIONS, 'Aggregation expression') + add(UPDATE_OPERATORS, 'Update operator') + add(ejsonWrappers(), 'EJSON / BSON type') + add(shellHelpers(), 'mongo shell helper') + add(COLLECTION_METHODS, 'Collection method') + add(CURSOR_METHODS, 'Cursor method') + add(LITERALS, 'Literal') + symbolIndexCache = index + return index +} + +type CallSignature = { doc: string; parameters: ReadonlyArray } + +const FILTER_PARAM = ['filter', 'Query predicate, for example `{ status: "active" }`.'] as const +const OPTIONS_PARAM = ['options', 'Only `{ upsert: true }` is supported here.'] as const + +const CALL_SIGNATURES: Record = { + find: { + doc: 'Returns the documents matching the filter.', + parameters: [FILTER_PARAM, ['projection', 'Fields to include (1) or exclude (0).']] + }, + findOne: { doc: 'Returns the first matching document.', parameters: [FILTER_PARAM] }, + count: { doc: 'Counts the matching documents.', parameters: [FILTER_PARAM] }, + countDocuments: { doc: 'Counts the matching documents.', parameters: [FILTER_PARAM] }, + aggregate: { + doc: 'Runs an aggregation pipeline.', + parameters: [['pipeline', 'Array of stage objects, for example `[{ $match: { … } }]`.']] + }, + insertOne: { + doc: 'Inserts a single document.', + parameters: [['document', 'The document to insert.']] + }, + insertMany: { + doc: 'Inserts several documents.', + parameters: [['documents', 'Array of documents to insert.']] + }, + updateOne: { + doc: 'Applies update operators to the first matching document.', + parameters: [ + FILTER_PARAM, + ['update', 'Update operators such as `{ $set: { … } }`, or a pipeline.'], + OPTIONS_PARAM + ] + }, + updateMany: { + doc: 'Applies update operators to every matching document.', + parameters: [ + FILTER_PARAM, + ['update', 'Update operators such as `{ $set: { … } }`, or a pipeline.'], + OPTIONS_PARAM + ] + }, + replaceOne: { + doc: 'Replaces the first matching document entirely.', + parameters: [FILTER_PARAM, ['replacement', 'The full replacement document.'], OPTIONS_PARAM] + }, + deleteOne: { doc: 'Deletes the first matching document.', parameters: [FILTER_PARAM] }, + deleteMany: { doc: 'Deletes every matching document.', parameters: [FILTER_PARAM] }, + sort: { + doc: 'Orders the result set.', + parameters: [['spec', 'Field to direction map, 1 ascending and -1 descending.']] + }, + skip: { doc: 'Skips the first n documents.', parameters: [['n', 'Non-negative integer.']] }, + limit: { doc: 'Caps the result size.', parameters: [['n', 'Non-negative integer.']] }, + ObjectId: { doc: 'ObjectId literal.', parameters: [['hex', '24 character hex string.']] }, + ISODate: { doc: 'Date literal.', parameters: [['iso', 'ISO-8601 date string.']] }, + Date: { doc: 'Date literal.', parameters: [['iso', 'ISO-8601 date string.']] }, + NumberLong: { doc: 'BSON 64 bit integer.', parameters: [['value', 'Integer as a string.']] }, + NumberInt: { doc: 'BSON 32 bit integer.', parameters: [['value', 'Integer value.']] }, + NumberDecimal: { doc: 'BSON 128 bit decimal.', parameters: [['value', 'Decimal as a string.']] }, + UUID: { doc: 'UUID literal.', parameters: [['uuid', 'Canonical UUID string.']] }, + JUUID: { doc: 'Legacy Java driver UUID.', parameters: [['uuid', 'Canonical UUID string.']] }, + BinData: { + doc: 'Binary data.', + parameters: [ + ['subType', 'BSON binary subtype, for example 0.'], + ['base64', 'Payload as base64.'] + ] + }, + Timestamp: { + doc: 'BSON replication timestamp.', + parameters: [ + ['t', 'Seconds since the epoch.'], + ['i', 'Ordinal within the second.'] + ] + }, + DBRef: { + doc: 'Document reference.', + parameters: [ + ['coll', 'Referenced collection.'], + ['id', 'Referenced _id.'] + ] + }, + Code: { doc: 'BSON code value.', parameters: [['source', 'JavaScript source.']] }, + RegExp: { + doc: 'Regular expression.', + parameters: [ + ['pattern', 'Regex pattern.'], + ['flags', 'Regex flags such as i or m.'] + ] + } +} + +type EnclosingCall = { name: string; argIndex: number } + +function enclosingCall(text: string, scan: BracketScan): EnclosingCall | null { + for (let i = scan.stack.length - 1; i >= 0; i--) { + const entry = scan.stack[i]! + if (entry.char !== '(') continue + const head = /([A-Za-z_$][\w$]*)\s*$/.exec(text.slice(0, entry.index)) + if (!head) return null + return { name: head[1]!, argIndex: countTopLevelCommas(text.slice(entry.index + 1)) } + } + return null +} + +function countTopLevelCommas(text: string): number { + let depth = 0 + let commas = 0 + let i = 0 + while (i < text.length) { + const char = text.charAt(i) + if (char === '"' || char === "'") { + const end = skipStringLiteral(text, i) + if (end < 0) return commas + i = end + continue + } + if (char === '{' || char === '[' || char === '(') depth++ + else if (char === '}' || char === ']' || char === ')') depth-- + else if (char === ',' && depth === 0) commas++ + i++ + } + return commas +} + +function isUpdatePosition(context: EditorCompletionContext, call: EnclosingCall | null): boolean { + if (context.kind !== 'shell' || call === null) return false + return (call.name === 'updateOne' || call.name === 'updateMany') && call.argIndex === 1 +} + +function valueSignatures( + context: EditorCompletionContext, + call: EnclosingCall | null +): readonly Signature[] | null { + if (context.kind === 'query') { + if (context.role === 'sort') return SORT_VALUES + if (context.role === 'projection') return PROJECTION_VALUES + return null + } + if (context.kind !== 'shell' || call === null) return null + if (call.name === 'sort') return SORT_VALUES + if (call.name === 'find' && call.argIndex === 1) return PROJECTION_VALUES + return null +} + +function plainItems( + signatures: readonly Signature[], + kind: SuggestionKind, + detail: string +): CompletionItemData[] { + return signatures.map(([label, insertText, doc]) => ({ + label, + insertText, + filterText: label, + doc, + detail, + kind, + sortText: label, + retrigger: false + })) +} + +const SORT_VALUES: readonly Signature[] = [ + ['1', '1', 'Ascending order.'], + ['-1', '-1', 'Descending order.'] +] + +const PROJECTION_VALUES: readonly Signature[] = [ + ['1', '1', 'Include the field.'], + ['0', '0', 'Exclude the field.'] +] + +function isAggregationSource(context: EditorCompletionContext, text: string): boolean { + if (context.kind === 'pipeline') return true + if (context.kind === 'shell') return /\baggregate\s*\(/.test(text) + return false +} + +function stagePositionOf( + context: EditorCompletionContext, + text: string, + scan: BracketScan +): StagePosition | null { + if (!isAggregationSource(context, text)) return null + const top = scan.stack[scan.stack.length - 1] + if (!top) return null + if (top.char === '[') return 'array' + if (top.char === '{' && scan.stack[scan.stack.length - 2]?.char === '[') return 'object' + return null +} + +function usesAggregationExpressions( + context: EditorCompletionContext, + text: string, + scan: BracketScan +): boolean { + if (!isAggregationSource(context, text)) return false + const stage = enclosingStage(text, scan) + if (!stage) return false + return stage.name !== '$match' || stage.body.includes('$expr') +} + +function enclosingStage(text: string, scan: BracketScan): { name: string; body: string } | null { + const index = scan.stack.findIndex( + (entry, i) => entry.char === '{' && scan.stack[i - 1]?.char === '[' + ) + const entry = index < 0 ? undefined : scan.stack[index] + if (!entry) return null + const body = text.slice(entry.index + 1) + const name = /^\s*"?(\$[A-Za-z]\w*)"?\s*:/.exec(body)?.[1] + return name ? { name, body } : null +} + +function scanBrackets(text: string): BracketScan { + const stack: BracketEntry[] = [] + let i = 0 + while (i < text.length) { + const char = text.charAt(i) + if (char === '"' || char === "'") { + const end = skipStringLiteral(text, i) + if (end < 0) return { stack, inString: true } + i = end + continue + } + if (char === '{' || char === '[' || char === '(') stack.push({ char, index: i }) + else if (char === '}' || char === ']' || char === ')') stack.pop() + i++ + } + return { stack, inString: false } +} + +function skipStringLiteral(text: string, start: number): number { + const quote = text.charAt(start) + let i = start + 1 + while (i < text.length) { + const char = text.charAt(i) + if (char === '\\') { + i += 2 + continue + } + if (char === quote) return i + 1 + i++ + } + return -1 +} + +const COLLECTION_METHODS: readonly Signature[] = [ + ['find', 'find({ $0 })', 'Returns the documents matching the filter.'], + ['findOne', 'findOne({ $0 })', 'Returns the first document matching the filter.'], + ['aggregate', 'aggregate([\n\t{ $0 }\n])', 'Runs an aggregation pipeline.'], + ['countDocuments', 'countDocuments({ $0 })', 'Counts the documents matching the filter.'], + ['count', 'count({ $0 })', 'Counts the documents matching the filter.'], + ['insertOne', 'insertOne({ $0 })', 'Inserts a single document.'], + ['insertMany', 'insertMany([\n\t{ $0 }\n])', 'Inserts several documents.'], + [ + 'updateOne', + 'updateOne({ $1 }, { "$set": { $0 } })', + 'Applies update operators to the first matching document.' + ], + [ + 'updateMany', + 'updateMany({ $1 }, { "$set": { $0 } })', + 'Applies update operators to every matching document.' + ], + ['replaceOne', 'replaceOne({ $1 }, { $0 })', 'Replaces the first matching document entirely.'], + ['deleteOne', 'deleteOne({ $0 })', 'Deletes the first matching document.'], + ['deleteMany', 'deleteMany({ $0 })', 'Deletes every matching document.'] +] + +const WRITE_METHOD_NAMES: ReadonlySet = new Set([ + 'insertOne', + 'insertMany', + 'updateOne', + 'updateMany', + 'replaceOne', + 'deleteOne', + 'deleteMany' +]) + +const NO_ARGUMENT_METHODS: readonly Signature[] = [ + ['toArray', 'toArray()', 'Materialises the cursor. Accepted and ignored here.'], + ['pretty', 'pretty()', 'Formats the output. Accepted and ignored here.'] +] + +const CURSOR_METHODS: readonly Signature[] = [ + ['sort', 'sort({ ${1:field}: ${2:-1} })', 'Orders the result set.'], + ['skip', 'skip(${1:0})', 'Skips the first n documents.'], + ['limit', 'limit(${1:50})', 'Caps the number of returned documents.'], + ...NO_ARGUMENT_METHODS +] + +const QUERY_OPERATORS: readonly Signature[] = [ + ['$eq', '${1:value}', 'Matches values equal to a specified value.'], + ['$ne', '${1:value}', 'Matches values not equal to a specified value.'], + ['$gt', '${1:value}', 'Matches values greater than a specified value.'], + ['$gte', '${1:value}', 'Matches values greater than or equal to a specified value.'], + ['$lt', '${1:value}', 'Matches values less than a specified value.'], + ['$lte', '${1:value}', 'Matches values less than or equal to a specified value.'], + ['$in', '[${1:values}]', 'Matches any of the values in the array.'], + ['$nin', '[${1:values}]', 'Matches none of the values in the array.'], + ['$and', '[\n\t{ $0 }\n]', 'Joins clauses with logical AND.'], + ['$or', '[\n\t{ $0 }\n]', 'Joins clauses with logical OR.'], + ['$nor', '[\n\t{ $0 }\n]', 'Joins clauses with logical NOR.'], + ['$not', '{ $0 }', 'Inverts the effect of an expression.'], + ['$exists', '${1|true,false|}', 'Matches documents that have or lack the field.'], + [ + '$type', + '"${1|double,string,object,array,binData,objectId,bool,date,null,regex,int,timestamp,long,decimal,minKey,maxKey|}"', + 'Matches documents whose field is one of the BSON types.' + ], + ['$expr', '{ $0 }', 'Allows the use of aggregation expressions inside the query.'], + ['$jsonSchema', '{ $0 }', 'Validates documents against a JSON Schema.'], + ['$mod', '[${1:divisor}, ${2:remainder}]', 'Performs a modulo operation.'], + ['$regex', '"${1:pattern}", "$options": "${2:i}"', 'Pattern match against a regex.'], + ['$options', '"${1:i}"', 'Regex options: i (case insensitive), m, x, s.'], + ['$text', '{ "$search": "${1:query}" }', 'Performs a text search.'], + ['$search', '"${1:query}"', 'Search string of a $text query.'], + ['$language', '"${1:english}"', 'Language used for the text search.'], + ['$caseSensitive', '${1|true,false|}', 'Case sensitive text search.'], + ['$diacriticSensitive', '${1|true,false|}', 'Diacritic sensitive text search.'], + ['$where', '"${1:this.field === value}"', 'Server side JavaScript predicate (slow).'], + ['$all', '[${1:values}]', 'Matches arrays containing all specified elements.'], + ['$elemMatch', '{ $0 }', 'Matches arrays with an element matching all criteria.'], + ['$size', '${1:n}', 'Matches arrays of the given length.'], + ['$bitsAllClear', '${1:mask}', 'All bits of the mask are clear (0).'], + ['$bitsAllSet', '${1:mask}', 'All bits of the mask are set (1).'], + ['$bitsAnyClear', '${1:mask}', 'Any bit of the mask is clear.'], + ['$bitsAnySet', '${1:mask}', 'Any bit of the mask is set.'], + [ + '$geoIntersects', + '{ "$geometry": { "type": "${1:Point}", "coordinates": [${2:0}, ${3:0}] } }', + 'Selects geometries intersecting a GeoJSON geometry.' + ], + [ + '$geoWithin', + '{ "$geometry": { "type": "${1:Polygon}", "coordinates": ${2:[[[0, 0]]]} } }', + 'Selects geometries within a bounding GeoJSON geometry.' + ], + [ + '$near', + '{ "$geometry": { "type": "Point", "coordinates": [${1:lng}, ${2:lat}] }, "$maxDistance": ${3:meters} }', + 'Returns documents ordered by proximity to a point.' + ], + [ + '$nearSphere', + '{ "$geometry": { "type": "Point", "coordinates": [${1:lng}, ${2:lat}] }, "$maxDistance": ${3:meters} }', + 'Like $near but uses spherical geometry.' + ], + ['$geometry', '{ "type": "${1:Point}", "coordinates": ${2:[0, 0]} }', 'GeoJSON geometry helper.'], + ['$maxDistance', '${1:meters}', 'Maximum distance in meters from the $near point.'], + ['$minDistance', '${1:meters}', 'Minimum distance in meters from the $near point.'], + [ + '$box', + '[[${1:lngLow}, ${2:latLow}], [${3:lngHigh}, ${4:latHigh}]]', + 'Legacy bounding box for $geoWithin.' + ], + ['$center', '[[${1:lng}, ${2:lat}], ${3:radius}]', 'Legacy flat circle for $geoWithin.'], + ['$centerSphere', '[[${1:lng}, ${2:lat}], ${3:radians}]', 'Spherical circle for $geoWithin.'], + [ + '$polygon', + '[[${1:lng1}, ${2:lat1}], [${3:lng2}, ${4:lat2}], [${5:lng3}, ${6:lat3}]]', + 'Legacy polygon for $geoWithin.' + ], + ['$slice', '${1:n}', 'Limits the array elements returned by a projection.'], + ['$meta', '"${1|textScore,indexKey|}"', 'Projects document metadata.'] +] + +const UPDATE_OPERATORS: readonly Signature[] = [ + ['$set', '{ "${1:field}": ${2:value} }', 'Sets the value of a field.'], + ['$unset', '{ "${1:field}": "" }', 'Removes a field.'], + ['$inc', '{ "${1:field}": ${2:1} }', 'Increments a numeric field.'], + ['$mul', '{ "${1:field}": ${2:2} }', 'Multiplies a numeric field.'], + ['$rename', '{ "${1:field}": "${2:newName}" }', 'Renames a field.'], + ['$min', '{ "${1:field}": ${2:value} }', 'Only updates when the new value is smaller.'], + ['$max', '{ "${1:field}": ${2:value} }', 'Only updates when the new value is larger.'], + ['$currentDate', '{ "${1:field}": true }', 'Sets a field to the current date.'], + ['$setOnInsert', '{ "${1:field}": ${2:value} }', 'Sets fields only on an upsert insert.'], + ['$push', '{ "${1:field}": ${2:value} }', 'Appends a value to an array.'], + ['$addToSet', '{ "${1:field}": ${2:value} }', 'Appends a value only when it is missing.'], + ['$pull', '{ "${1:field}": ${2:value} }', 'Removes matching values from an array.'], + ['$pullAll', '{ "${1:field}": [${2:values}] }', 'Removes all listed values from an array.'], + ['$pop', '{ "${1:field}": ${2|1,-1|} }', 'Removes the first (-1) or last (1) array element.'], + ['$each', '[${1:values}]', 'Modifier for $push and $addToSet to add several values.'], + ['$slice', '${1:10}', 'Modifier for $push that trims the array.'], + ['$sort', '{ "${1:field}": ${2:-1} }', 'Modifier for $push that orders the array.'], + ['$position', '${1:0}', 'Modifier for $push that sets the insert position.'], + ['$bit', '{ "${1:field}": { "and": ${2:1} } }', 'Bitwise update of an integer field.'] +] + +const AGGREGATION_STAGES: readonly Signature[] = [ + ['$addFields', '{ "${1:field}": ${2:expression} }', 'Adds new fields to the documents.'], + [ + '$bucket', + '{ "groupBy": "$${1:field}", "boundaries": [${2:0, 10}], "default": "${3:other}", "output": { $0 } }', + 'Groups documents into buckets by the given boundaries.' + ], + [ + '$bucketAuto', + '{ "groupBy": "$${1:field}", "buckets": ${2:5} }', + 'Groups documents into evenly distributed buckets.' + ], + ['$changeStream', '{ }', 'Returns a change stream cursor for the collection.'], + ['$collStats', '{ "storageStats": { } }', 'Returns statistics about the collection.'], + ['$count', '"${1:count}"', 'Counts the documents at this point of the pipeline.'], + [ + '$densify', + '{ "field": "${1:field}", "range": { "step": ${2:1}, "unit": "${3:day}", "bounds": "${4:full}" } }', + 'Creates the missing documents of a sequence.' + ], + ['$documents', '[${1:documents}]', 'Emits literal documents into the pipeline.'], + ['$facet', '{ "${1:name}": [\n\t\t$0\n\t] }', 'Runs several sub pipelines on the same input.'], + [ + '$fill', + '{ "output": { "${1:field}": { "method": "${2|linear,locf|}" } } }', + 'Fills in missing field values.' + ], + [ + '$geoNear', + '{ "near": { "type": "Point", "coordinates": [${1:lng}, ${2:lat}] }, "distanceField": "${3:distance}" }', + 'Orders the documents by proximity to a point.' + ], + [ + '$graphLookup', + '{ "from": "${1:collection}", "startWith": "$${2:field}", "connectFromField": "${3:field}", "connectToField": "${4:field}", "as": "${5:result}" }', + 'Performs a recursive search on a collection.' + ], + [ + '$group', + '{ "_id": "$${1:field}", "${2:count}": { "$sum": 1 } }', + 'Groups documents by a key and applies accumulators.' + ], + ['$indexStats', '{ }', 'Returns usage statistics for every index.'], + ['$limit', '${1:10}', 'Passes only the first n documents on.'], + [ + '$lookup', + '{ "from": "${1:collection}", "localField": "${2:field}", "foreignField": "${3:_id}", "as": "${4:result}" }', + 'Performs a left outer join with another collection.' + ], + ['$match', '{ $0 }', 'Filters the documents with a query predicate.'], + ['$merge', '{ "into": "${1:collection}" }', 'Writes the results into a collection.'], + ['$out', '"${1:collection}"', 'Replaces a collection with the pipeline results.'], + ['$project', '{ "${1:field}": 1 }', 'Includes, excludes or computes fields.'], + ['$redact', '"$$${1|DESCEND,PRUNE,KEEP|}"', 'Restricts documents based on their content.'], + ['$replaceRoot', '{ "newRoot": "$${1:field}" }', 'Promotes a sub document to the top level.'], + ['$replaceWith', '"$${1:field}"', 'Shorthand for $replaceRoot.'], + ['$sample', '{ "size": ${1:10} }', 'Selects a random sample of documents.'], + [ + '$search', + '{ "index": "${1:default}", "text": { "query": "${2:query}", "path": "${3:field}" } }', + 'Atlas Search full text query.' + ], + ['$set', '{ "${1:field}": ${2:expression} }', 'Adds or overwrites fields. Alias of $addFields.'], + [ + '$setWindowFields', + '{ "partitionBy": "$${1:field}", "sortBy": { "${2:field}": 1 }, "output": { $0 } }', + 'Computes window function output over partitions.' + ], + ['$skip', '${1:0}', 'Skips the first n documents.'], + ['$sort', '{ "${1:field}": ${2:-1} }', 'Orders the documents.'], + ['$sortByCount', '"$${1:field}"', 'Groups by an expression and sorts by descending count.'], + [ + '$unionWith', + '{ "coll": "${1:collection}", "pipeline": [\n\t\t$0\n\t] }', + 'Combines the results of two collections.' + ], + ['$unset', '"${1:field}"', 'Removes fields from the documents.'], + [ + '$unwind', + '{ "path": "$${1:field}", "preserveNullAndEmptyArrays": ${2|true,false|} }', + 'Deconstructs an array field into one document per element.' + ] +] + +const AGGREGATION_EXPRESSIONS: readonly Signature[] = [ + ['$abs', '${1:number}', 'Absolute value.'], + ['$add', '[${1:expression}, ${2:expression}]', 'Adds numbers or a date and numbers.'], + ['$ceil', '${1:number}', 'Smallest integer greater than or equal to the number.'], + ['$divide', '[${1:dividend}, ${2:divisor}]', 'Divides two numbers.'], + ['$exp', '${1:exponent}', 'Raises e to the given exponent.'], + ['$floor', '${1:number}', 'Largest integer less than or equal to the number.'], + ['$ln', '${1:number}', 'Natural logarithm.'], + ['$log', '[${1:number}, ${2:base}]', 'Logarithm in the given base.'], + ['$log10', '${1:number}', 'Logarithm in base 10.'], + ['$mod', '[${1:dividend}, ${2:divisor}]', 'Remainder of a division.'], + ['$multiply', '[${1:expression}, ${2:expression}]', 'Multiplies numbers.'], + ['$pow', '[${1:number}, ${2:exponent}]', 'Raises a number to an exponent.'], + ['$round', '[${1:number}, ${2:place}]', 'Rounds to a whole number or decimal place.'], + ['$sqrt', '${1:number}', 'Square root.'], + ['$subtract', '[${1:minuend}, ${2:subtrahend}]', 'Subtracts numbers or dates.'], + ['$trunc', '[${1:number}, ${2:place}]', 'Truncates to a whole number or decimal place.'], + ['$arrayElemAt', '[${1:array}, ${2:index}]', 'Element of an array at the given index.'], + ['$arrayToObject', '${1:array}', 'Converts key value pairs into a document.'], + ['$concatArrays', '[${1:array}, ${2:array}]', 'Concatenates arrays.'], + [ + '$filter', + '{ "input": "$${1:array}", "as": "${2:item}", "cond": { $0 } }', + 'Selects the array elements matching a condition.' + ], + ['$first', '"$${1:field}"', 'First element or first value of a group.'], + ['$firstN', '{ "input": "$${1:array}", "n": ${2:3} }', 'First n elements of an array.'], + ['$in', '[${1:value}, ${2:array}]', 'True when the value is contained in the array.'], + ['$indexOfArray', '[${1:array}, ${2:value}]', 'Index of the first matching array element.'], + ['$isArray', '${1:expression}', 'True when the expression is an array.'], + ['$last', '"$${1:field}"', 'Last element or last value of a group.'], + ['$lastN', '{ "input": "$${1:array}", "n": ${2:3} }', 'Last n elements of an array.'], + [ + '$map', + '{ "input": "$${1:array}", "as": "${2:item}", "in": { $0 } }', + 'Applies an expression to every array element.' + ], + ['$objectToArray', '${1:object}', 'Converts a document into key value pairs.'], + ['$range', '[${1:start}, ${2:end}, ${3:step}]', 'Generates a sequence of numbers.'], + [ + '$reduce', + '{ "input": "$${1:array}", "initialValue": ${2:0}, "in": { $0 } }', + 'Folds an array into a single value.' + ], + ['$reverseArray', '${1:array}', 'Reverses an array.'], + ['$size', '"$${1:array}"', 'Number of elements of an array.'], + ['$slice', '[${1:array}, ${2:n}]', 'Subset of an array.'], + [ + '$sortArray', + '{ "input": "$${1:array}", "sortBy": { "${2:field}": 1 } }', + 'Sorts the elements of an array.' + ], + ['$zip', '{ "inputs": [${1:arrays}] }', 'Transposes arrays into an array of tuples.'], + ['$and', '[${1:expression}, ${2:expression}]', 'Logical AND of expressions.'], + ['$or', '[${1:expression}, ${2:expression}]', 'Logical OR of expressions.'], + ['$not', '[${1:expression}]', 'Logical NOT of an expression.'], + ['$cmp', '[${1:expression}, ${2:expression}]', 'Compares two values and returns -1, 0 or 1.'], + ['$eq', '[${1:expression}, ${2:expression}]', 'True when both values are equal.'], + ['$ne', '[${1:expression}, ${2:expression}]', 'True when both values differ.'], + ['$gt', '[${1:expression}, ${2:expression}]', 'True when the first value is greater.'], + ['$gte', '[${1:expression}, ${2:expression}]', 'True when the first value is greater or equal.'], + ['$lt', '[${1:expression}, ${2:expression}]', 'True when the first value is smaller.'], + ['$lte', '[${1:expression}, ${2:expression}]', 'True when the first value is smaller or equal.'], + [ + '$cond', + '{ "if": { $1 }, "then": ${2:value}, "else": ${3:value} }', + 'Ternary conditional expression.' + ], + ['$ifNull', '[${1:expression}, ${2:fallback}]', 'Returns a fallback when the value is null.'], + [ + '$switch', + '{ "branches": [\n\t{ "case": { $1 }, "then": ${2:value} }\n], "default": ${3:value} }', + 'Multi branch conditional expression.' + ], + ['$literal', '${1:value}', 'Returns the value without parsing it as an expression.'], + [ + '$let', + '{ "vars": { "${1:name}": ${2:value} }, "in": { $0 } }', + 'Binds variables for a sub expression.' + ], + [ + '$dateAdd', + '{ "startDate": "$${1:field}", "unit": "${2|year,quarter,week,month,day,hour,minute,second|}", "amount": ${3:1} }', + 'Adds a time interval to a date.' + ], + [ + '$dateDiff', + '{ "startDate": "$${1:field}", "endDate": "$${2:field}", "unit": "${3|year,quarter,week,month,day,hour,minute,second|}" }', + 'Difference between two dates in the given unit.' + ], + ['$dateFromString', '{ "dateString": "$${1:field}" }', 'Converts a string into a date.'], + [ + '$dateSubtract', + '{ "startDate": "$${1:field}", "unit": "${2|year,quarter,week,month,day,hour,minute,second|}", "amount": ${3:1} }', + 'Subtracts a time interval from a date.' + ], + ['$dateToParts', '{ "date": "$${1:field}" }', 'Splits a date into its components.'], + [ + '$dateToString', + '{ "date": "$${1:field}", "format": "${2:%Y-%m-%d}" }', + 'Formats a date as a string.' + ], + [ + '$dateTrunc', + '{ "date": "$${1:field}", "unit": "${2|year,quarter,week,month,day,hour,minute,second|}" }', + 'Truncates a date to the given unit.' + ], + ['$dayOfMonth', '"$${1:field}"', 'Day of the month (1-31).'], + ['$dayOfWeek', '"$${1:field}"', 'Day of the week (1 = Sunday).'], + ['$dayOfYear', '"$${1:field}"', 'Day of the year (1-366).'], + ['$hour', '"$${1:field}"', 'Hour of a date (0-23).'], + ['$isoWeek', '"$${1:field}"', 'ISO week number.'], + ['$isoWeekYear', '"$${1:field}"', 'ISO week year.'], + ['$millisecond', '"$${1:field}"', 'Milliseconds of a date.'], + ['$minute', '"$${1:field}"', 'Minute of a date (0-59).'], + ['$month', '"$${1:field}"', 'Month of a date (1-12).'], + ['$second', '"$${1:field}"', 'Seconds of a date (0-60).'], + ['$week', '"$${1:field}"', 'Week of the year.'], + ['$year', '"$${1:field}"', 'Year of a date.'], + ['$concat', '[${1:expression}, ${2:expression}]', 'Concatenates strings.'], + ['$ltrim', '{ "input": "$${1:field}" }', 'Removes leading whitespace.'], + [ + '$regexFind', + '{ "input": "$${1:field}", "regex": "${2:pattern}", "options": "${3:i}" }', + 'Returns the first regex match.' + ], + [ + '$regexFindAll', + '{ "input": "$${1:field}", "regex": "${2:pattern}", "options": "${3:i}" }', + 'Returns all regex matches.' + ], + [ + '$regexMatch', + '{ "input": "$${1:field}", "regex": "${2:pattern}", "options": "${3:i}" }', + 'True when the regex matches.' + ], + [ + '$replaceAll', + '{ "input": "$${1:field}", "find": "${2:search}", "replacement": "${3:replacement}" }', + 'Replaces all occurrences of a substring.' + ], + [ + '$replaceOne', + '{ "input": "$${1:field}", "find": "${2:search}", "replacement": "${3:replacement}" }', + 'Replaces the first occurrence of a substring.' + ], + ['$rtrim', '{ "input": "$${1:field}" }', 'Removes trailing whitespace.'], + ['$split', '["$${1:field}", "${2:separator}"]', 'Splits a string into an array.'], + ['$strLenCP', '"$${1:field}"', 'Number of code points of a string.'], + ['$strcasecmp', '["$${1:field}", "${2:value}"]', 'Case insensitive string comparison.'], + ['$substrCP', '["$${1:field}", ${2:start}, ${3:length}]', 'Substring by code points.'], + ['$toLower', '"$${1:field}"', 'Converts a string to lower case.'], + ['$toUpper', '"$${1:field}"', 'Converts a string to upper case.'], + ['$trim', '{ "input": "$${1:field}" }', 'Removes leading and trailing whitespace.'], + ['$addToSet', '"$${1:field}"', 'Collects the unique values of a group.'], + ['$avg', '"$${1:field}"', 'Average of numeric values.'], + [ + '$bottom', + '{ "sortBy": { "${1:field}": 1 }, "output": "$${2:field}" }', + 'Bottom element of a group.' + ], + ['$count', '{ }', 'Counts the documents of a group.'], + ['$max', '"$${1:field}"', 'Maximum value of a group.'], + ['$mergeObjects', '["$${1:field}", "$${2:field}"]', 'Merges documents into a single document.'], + ['$min', '"$${1:field}"', 'Minimum value of a group.'], + ['$push', '"$${1:field}"', 'Collects all values of a group into an array.'], + ['$stdDevPop', '"$${1:field}"', 'Population standard deviation.'], + ['$stdDevSamp', '"$${1:field}"', 'Sample standard deviation.'], + ['$sum', '${1:1}', 'Sum of numeric values.'], + ['$top', '{ "sortBy": { "${1:field}": 1 }, "output": "$${2:field}" }', 'Top element of a group.'], + [ + '$convert', + '{ "input": "$${1:field}", "to": "${2|double,string,objectId,bool,date,int,long,decimal|}" }', + 'Converts a value into the given type.' + ], + ['$isNumber', '"$${1:field}"', 'True when the value is numeric.'], + ['$toBool', '"$${1:field}"', 'Converts a value into a boolean.'], + ['$toDate', '"$${1:field}"', 'Converts a value into a date.'], + ['$toDecimal', '"$${1:field}"', 'Converts a value into a decimal.'], + ['$toDouble', '"$${1:field}"', 'Converts a value into a double.'], + ['$toInt', '"$${1:field}"', 'Converts a value into a 32 bit integer.'], + ['$toLong', '"$${1:field}"', 'Converts a value into a 64 bit integer.'], + ['$toObjectId', '"$${1:field}"', 'Converts a value into an ObjectId.'], + ['$toString', '"$${1:field}"', 'Converts a value into a string.'], + ['$type', '"$${1:field}"', 'BSON type of the value.'] +] + +function ejsonWrappers(): readonly Signature[] { + const nowIso = new Date().toISOString() + const nowSeconds = Math.floor(Date.now() / 1000) + return [ + ['$oid', '"${1:507f1f77bcf86cd799439011}"', 'EJSON: ObjectId wrapper.'], + ['$date', '"${1:' + nowIso + '}"', 'EJSON: date wrapper (ISO-8601 string).'], + ['$numberLong', '"${1:0}"', 'EJSON: 64 bit integer.'], + ['$numberInt', '"${1:0}"', 'EJSON: 32 bit integer.'], + ['$numberDouble', '"${1:0.0}"', 'EJSON: 64 bit float.'], + ['$numberDecimal', '"${1:0}"', 'EJSON: 128 bit decimal.'], + ['$binary', '{ "base64": "${1:}", "subType": "${2:00}" }', 'EJSON: BSON binary.'], + [ + '$timestamp', + '{ "t": ${1:' + nowSeconds + '}, "i": ${2:0} }', + 'EJSON: BSON timestamp (replication).' + ], + ['$uuid', '"${1:00000000-0000-0000-0000-000000000000}"', 'EJSON: UUID (binary subType 4).'], + [ + '$regularExpression', + '{ "pattern": "${1:}", "options": "${2:i}" }', + 'EJSON: BSON regex (canonical form).' + ], + ['$symbol', '"${1:}"', 'EJSON: deprecated BSON symbol.'], + ['$code', '"${1:function() {}}"', 'EJSON: BSON code.'], + ['$minKey', '1', 'EJSON: BSON MinKey marker.'], + ['$maxKey', '1', 'EJSON: BSON MaxKey marker.'], + ['$undefined', 'true', 'EJSON: deprecated BSON undefined.'] + ] +} + +function shellHelpers(): readonly Signature[] { + const nowIso = new Date().toISOString() + const nowSeconds = Math.floor(Date.now() / 1000) + return [ + ['ObjectId', 'ObjectId("${1:507f1f77bcf86cd799439011}")', 'ObjectId hex literal.'], + ['ISODate', 'ISODate("${1:' + nowIso + '}")', 'ISO-8601 date, defaults to now.'], + ['Date', 'Date("${1:' + nowIso + '}")', 'Same as ISODate, defaults to now.'], + ['NumberLong', 'NumberLong("${1:0}")', 'BSON 64 bit integer.'], + ['NumberInt', 'NumberInt(${1:0})', 'BSON 32 bit integer.'], + ['NumberDecimal', 'NumberDecimal("${1:0}")', 'BSON 128 bit decimal.'], + ['UUID', 'UUID("${1:00000000-0000-0000-0000-000000000000}")', 'UUID literal (subType 04).'], + [ + 'JUUID', + 'JUUID("${1:00000000-0000-0000-0000-000000000000}")', + 'Legacy Java driver UUID (subType 03).' + ], + ['BinData', 'BinData(${1:0}, "${2:base64==}")', 'Binary data with subType.'], + ['Timestamp', 'Timestamp(${1:' + nowSeconds + '}, ${2:0})', 'BSON timestamp (replication).'], + ['MinKey', 'MinKey', 'Sorts before any other BSON value.'], + ['MaxKey', 'MaxKey', 'Sorts after any other BSON value.'], + ['DBRef', 'DBRef("${1:coll}", ${2:id})', 'Document reference.'], + ['Code', 'Code("${1:function() {}}")', 'BSON code value.'], + ['RegExp', 'RegExp("${1:pattern}", "${2:i}")', 'Regex, equivalent to a /pattern/flags literal.'] + ] +} + +const LITERALS: readonly Signature[] = [ + ['true', 'true', 'Boolean true.'], + ['false', 'false', 'Boolean false.'], + ['null', 'null', 'Null value.'], + ['undefined', 'undefined', 'BSON undefined (deprecated).'] +] diff --git a/src/renderer/src/lib/shellParser.test.ts b/src/renderer/src/lib/shellParser.test.ts index 835c7db..212362d 100644 --- a/src/renderer/src/lib/shellParser.test.ts +++ b/src/renderer/src/lib/shellParser.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { parseShellCommand } from './shellParser' +import { affectsWholeCollection, isWriteOp, parseShellCommand } from './shellParser' describe('parseShellCommand', () => { it('parses a bare find()', () => { @@ -71,7 +71,7 @@ describe('parseShellCommand', () => { }) it('rejects unsupported methods', () => { - const r = parseShellCommand('db.x.insertOne({})') + const r = parseShellCommand('db.x.distinct("name")') expect(r.ok).toBe(false) if (r.ok) return expect(r.error).toMatch(/Unsupported method/) @@ -108,3 +108,102 @@ describe('parseShellCommand', () => { expect(r.ok).toBe(false) }) }) + +describe('parseShellCommand write operations', () => { + it('parses insertOne', () => { + const r = parseShellCommand('db.users.insertOne({ name: "ada", age: NumberInt(36) })') + expect(r.ok).toBe(true) + if (!r.ok || r.op.kind !== 'insertOne') return + expect(JSON.parse(r.op.document)).toEqual({ name: 'ada', age: { $numberInt: '36' } }) + expect(isWriteOp(r.op)).toBe(true) + }) + + it('parses insertMany into separate documents', () => { + const r = parseShellCommand('db.users.insertMany([{ a: 1 }, { b: 2 }])') + expect(r.ok).toBe(true) + if (!r.ok || r.op.kind !== 'insertMany') return + expect(r.op.documents).toEqual(['{"a":1}', '{"b":2}']) + }) + + it('rejects insertMany with a non object entry', () => { + expect(parseShellCommand('db.users.insertMany([1])').ok).toBe(false) + expect(parseShellCommand('db.users.insertMany([])').ok).toBe(false) + }) + + it('parses updateOne and updateMany', () => { + const one = parseShellCommand('db.users.updateOne({ _id: 1 }, { $set: { active: true } })') + expect(one.ok).toBe(true) + if (!one.ok || one.op.kind !== 'updateOne') return + expect(JSON.parse(one.op.filter)).toEqual({ _id: 1 }) + expect(JSON.parse(one.op.update)).toEqual({ $set: { active: true } }) + expect(one.op.upsert).toBe(false) + + const many = parseShellCommand('db.users.updateMany({}, { $unset: { tmp: "" } })') + expect(many.ok && many.op.kind === 'updateMany').toBe(true) + }) + + it('reads the upsert option', () => { + const r = parseShellCommand( + 'db.users.updateOne({ a: 1 }, { $set: { b: 2 } }, { upsert: true })' + ) + expect(r.ok).toBe(true) + if (!r.ok || r.op.kind !== 'updateOne') return + expect(r.op.upsert).toBe(true) + }) + + it('rejects unsupported options', () => { + const r = parseShellCommand('db.users.updateOne({}, { $set: { a: 1 } }, { arrayFilters: [] })') + expect(r.ok).toBe(false) + if (r.ok) return + expect(r.error).toMatch(/arrayFilters/) + }) + + it('requires update operators in an update document', () => { + const r = parseShellCommand('db.users.updateOne({ a: 1 }, { b: 2 })') + expect(r.ok).toBe(false) + if (r.ok) return + expect(r.error).toMatch(/update operators/) + }) + + it('accepts an aggregation pipeline as update', () => { + const r = parseShellCommand('db.users.updateMany({}, [{ $set: { n: { $add: ["$n", 1] } } }])') + expect(r.ok).toBe(true) + }) + + it('parses replaceOne', () => { + const r = parseShellCommand('db.users.replaceOne({ _id: 1 }, { name: "ada" })') + expect(r.ok).toBe(true) + if (!r.ok || r.op.kind !== 'replaceOne') return + expect(JSON.parse(r.op.replacement)).toEqual({ name: 'ada' }) + }) + + it('parses deleteOne and deleteMany', () => { + const one = parseShellCommand('db.users.deleteOne({ _id: 1 })') + expect(one.ok && one.op.kind === 'deleteOne').toBe(true) + const many = parseShellCommand('db.users.deleteMany({ archived: true })') + expect(many.ok && many.op.kind === 'deleteMany').toBe(true) + }) + + it('requires an explicit filter for deletes and updates', () => { + expect(parseShellCommand('db.users.deleteMany()').ok).toBe(false) + expect(parseShellCommand('db.users.updateOne({})').ok).toBe(false) + }) + + it('flags commands that hit the whole collection', () => { + const wide = parseShellCommand('db.users.deleteMany({})') + expect(wide.ok && affectsWholeCollection(wide.op)).toBe(true) + const narrow = parseShellCommand('db.users.deleteMany({ a: 1 })') + expect(narrow.ok && affectsWholeCollection(narrow.op)).toBe(false) + const read = parseShellCommand('db.users.find({})') + expect(read.ok && affectsWholeCollection(read.op)).toBe(false) + }) + + it('rejects chained calls on write operations', () => { + expect(parseShellCommand('db.users.deleteMany({}).limit(1)').ok).toBe(false) + }) + + it('separates reads from writes', () => { + const read = parseShellCommand('db.users.find({})') + expect(read.ok && isWriteOp(read.op)).toBe(false) + }) +}) diff --git a/src/renderer/src/lib/shellParser.ts b/src/renderer/src/lib/shellParser.ts index 0468f65..aeebbd3 100644 --- a/src/renderer/src/lib/shellParser.ts +++ b/src/renderer/src/lib/shellParser.ts @@ -12,13 +12,20 @@ import { parseMongoQuery } from './mongoQueryLang' * db.coll.countDocuments(?) * db.coll.count(?) * db.coll.aggregate() + * db.coll.insertOne() + * db.coll.insertMany() + * db.coll.updateOne(, [, { upsert: true }]) + * db.coll.updateMany(, [, { upsert: true }]) + * db.coll.replaceOne(, [, { upsert: true }]) + * db.coll.deleteOne() + * db.coll.deleteMany() * - * The collection name in the source is informational only — callers know - * which tab they're in and pass that explicitly to the API. Mismatch - * surfaces as a "wrong collection" parse error so the user notices. + * The collection name in the source is returned to the caller, which + * dispatches against it — a command may target a different collection + * than the tab it was typed in. */ -export type ShellOp = +export type ShellReadOp = | { kind: 'find' filter: string @@ -31,10 +38,52 @@ export type ShellOp = | { kind: 'countDocuments'; filter: string } | { kind: 'aggregate'; pipeline: string } +export type ShellWriteOp = + | { kind: 'insertOne'; document: string } + | { kind: 'insertMany'; documents: string[] } + | { kind: 'updateOne'; filter: string; update: string; upsert: boolean } + | { kind: 'updateMany'; filter: string; update: string; upsert: boolean } + | { kind: 'replaceOne'; filter: string; replacement: string; upsert: boolean } + | { kind: 'deleteOne'; filter: string } + | { kind: 'deleteMany'; filter: string } + +export type ShellOp = ShellReadOp | ShellWriteOp + +const WRITE_KINDS: ReadonlySet = new Set([ + 'insertOne', + 'insertMany', + 'updateOne', + 'updateMany', + 'replaceOne', + 'deleteOne', + 'deleteMany' +]) + +export function isWriteOp(op: ShellOp): op is ShellWriteOp { + return WRITE_KINDS.has(op.kind) +} + +/** + * True when the command would rewrite or drop every document of the + * collection, which is worth an explicit confirmation before running. + */ +export function affectsWholeCollection(op: ShellOp): boolean { + if (op.kind !== 'deleteMany' && op.kind !== 'updateMany') return false + return op.filter === '{}' +} + export type ShellParseResult = | { ok: true; coll: string; op: ShellOp } | { ok: false; error: string } +/** A write command the user asked to execute, ready to dispatch. */ +export type ShellWriteRequest = { + /** Verbatim source, used to tie a result to the command that produced it. */ + command: string + coll: string + op: ShellWriteOp +} + const HEAD_RE = /^\s*db\s*\.\s*([A-Za-z_$][\w.$]*)\s*\.\s*([A-Za-z_$][\w$]*)\s*\(/ export function parseShellCommand(input: string): ShellParseResult { @@ -126,12 +175,169 @@ export function parseShellCommand(input: string): ShellParseResult { if (!trailer.ok) return { ok: false, error: trailer.error } return { ok: true, coll, op: { kind: 'aggregate', pipeline: parsed.ejson } } } + case 'insertOne': { + const args = expectArgs(argsSrc, method, 1, 1) + if (!args.ok) return { ok: false, error: args.error } + const document = compileObject(args.parts[0]!, 'document') + if (!document.ok) return { ok: false, error: document.error } + const trailer = expectNoTrailer(input.slice(argsEnd + 1)) + if (!trailer.ok) return { ok: false, error: trailer.error } + return { ok: true, coll, op: { kind: 'insertOne', document: document.ejson } } + } + case 'insertMany': { + const args = expectArgs(argsSrc, method, 1, 1) + if (!args.ok) return { ok: false, error: args.error } + const parsed = parseMongoQuery(args.parts[0]!) + if (!parsed.ok) return { ok: false, error: `documents: ${parsed.error}` } + if (!Array.isArray(parsed.value) || parsed.value.length === 0) { + return { ok: false, error: 'documents: must be a non-empty array of documents' } + } + const documents: string[] = [] + for (const entry of parsed.value) { + if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) { + return { ok: false, error: 'documents: every entry must be an object' } + } + documents.push(JSON.stringify(entry)) + } + const trailer = expectNoTrailer(input.slice(argsEnd + 1)) + if (!trailer.ok) return { ok: false, error: trailer.error } + return { ok: true, coll, op: { kind: 'insertMany', documents } } + } + case 'updateOne': + case 'updateMany': { + const args = expectArgs(argsSrc, method, 2, 3) + if (!args.ok) return { ok: false, error: args.error } + const filter = compileObject(args.parts[0]!, 'filter') + if (!filter.ok) return { ok: false, error: filter.error } + const update = compileUpdate(args.parts[1]!) + if (!update.ok) return { ok: false, error: update.error } + const options = compileWriteOptions(args.parts[2]) + if (!options.ok) return { ok: false, error: options.error } + const trailer = expectNoTrailer(input.slice(argsEnd + 1)) + if (!trailer.ok) return { ok: false, error: trailer.error } + return { + ok: true, + coll, + op: { + kind: method === 'updateOne' ? 'updateOne' : 'updateMany', + filter: filter.ejson, + update: update.ejson, + upsert: options.upsert + } + } + } + case 'replaceOne': { + const args = expectArgs(argsSrc, method, 2, 3) + if (!args.ok) return { ok: false, error: args.error } + const filter = compileObject(args.parts[0]!, 'filter') + if (!filter.ok) return { ok: false, error: filter.error } + const replacement = compileObject(args.parts[1]!, 'replacement') + if (!replacement.ok) return { ok: false, error: replacement.error } + const options = compileWriteOptions(args.parts[2]) + if (!options.ok) return { ok: false, error: options.error } + const trailer = expectNoTrailer(input.slice(argsEnd + 1)) + if (!trailer.ok) return { ok: false, error: trailer.error } + return { + ok: true, + coll, + op: { + kind: 'replaceOne', + filter: filter.ejson, + replacement: replacement.ejson, + upsert: options.upsert + } + } + } + case 'deleteOne': + case 'deleteMany': { + const args = expectArgs(argsSrc, method, 1, 1) + if (!args.ok) return { ok: false, error: args.error } + const filter = compileObject(args.parts[0]!, 'filter') + if (!filter.ok) return { ok: false, error: filter.error } + const trailer = expectNoTrailer(input.slice(argsEnd + 1)) + if (!trailer.ok) return { ok: false, error: trailer.error } + return { + ok: true, + coll, + op: { kind: method === 'deleteOne' ? 'deleteOne' : 'deleteMany', filter: filter.ejson } + } + } default: return { ok: false, - error: `Unsupported method: ${method}. Try find, findOne, aggregate, count, countDocuments.` + error: `Unsupported method: ${method}. Try find, findOne, aggregate, count, countDocuments, insertOne, insertMany, updateOne, updateMany, replaceOne, deleteOne or deleteMany.` + } + } +} + +type ArgsResult = { ok: true; parts: string[] } | { ok: false; error: string } + +function expectArgs(argsSrc: string, method: string, min: number, max: number): ArgsResult { + const split = splitTopLevelArgs(argsSrc) + if (!split.ok) return { ok: false, error: split.error } + const parts = [...split.parts] + while (parts.length > 0 && (parts[parts.length - 1] ?? '').length === 0) parts.pop() + if (parts.length < min || parts.length > max) { + const expected = min === max ? `${min}` : `${min} to ${max}` + return { + ok: false, + error: `\`${method}\` expects ${expected} argument(s), got ${parts.length}` + } + } + for (let i = 0; i < min; i++) { + if ((parts[i] ?? '').length === 0) { + return { ok: false, error: `\`${method}\` argument ${i + 1} must not be empty` } + } + } + return { ok: true, parts } +} + +type UpdateResult = { ok: true; ejson: string } | { ok: false; error: string } + +function compileUpdate(src: string): UpdateResult { + const parsed = parseMongoQuery(src) + if (!parsed.ok) return { ok: false, error: `update: ${parsed.error}` } + if (Array.isArray(parsed.value)) { + for (const stage of parsed.value) { + if (stage === null || typeof stage !== 'object' || Array.isArray(stage)) { + return { ok: false, error: 'update: every pipeline stage must be an object' } } + } + return { ok: true, ejson: parsed.ejson } + } + if (parsed.value === null || typeof parsed.value !== 'object') { + return { ok: false, error: 'update: must be an object' } } + const keys = Object.keys(parsed.value as Record) + if (keys.length === 0 || !keys.every((key) => key.startsWith('$'))) { + return { + ok: false, + error: 'update: must only contain update operators such as $set. Use replaceOne otherwise.' + } + } + return { ok: true, ejson: parsed.ejson } +} + +type WriteOptionsResult = { ok: true; upsert: boolean } | { ok: false; error: string } + +function compileWriteOptions(src: string | undefined): WriteOptionsResult { + if (src === undefined || src.trim().length === 0) return { ok: true, upsert: false } + const parsed = parseMongoQuery(src) + if (!parsed.ok) return { ok: false, error: `options: ${parsed.error}` } + if (parsed.value === null || typeof parsed.value !== 'object' || Array.isArray(parsed.value)) { + return { ok: false, error: 'options: must be an object' } + } + const options = parsed.value as Record + for (const key of Object.keys(options)) { + if (key !== 'upsert') { + return { ok: false, error: `options: unsupported option "${key}". Only upsert is supported.` } + } + } + const upsert = options['upsert'] + if (upsert !== undefined && typeof upsert !== 'boolean') { + return { ok: false, error: 'options: upsert must be true or false' } + } + return { ok: true, upsert: upsert === true } } type CompiledObject = { ok: true; ejson: string } | { ok: false; error: string } @@ -150,7 +356,7 @@ type TrailerResult = | { ok: true; sort: string | null; skip: number | null; limit: number | null } | { ok: false; error: string } -function parseTrailer(rest: string): TrailerResult { +function parseTrailer(rest: string, cursorMethods = true): TrailerResult { let s = rest.trim() let sort: string | null = null let skip: number | null = null @@ -168,6 +374,10 @@ function parseTrailer(rest: string): TrailerResult { if (argsEnd < 0) return { ok: false, error: `Missing closing ')' on .${name}(...)` } const argSrc = s.slice(argsStart, argsEnd).trim() + if (!cursorMethods && (name === 'sort' || name === 'skip' || name === 'limit')) { + return { ok: false, error: `.${name}() is only supported on find()` } + } + switch (name) { case 'sort': { if (sort !== null) return { ok: false, error: '.sort() specified twice' } @@ -208,9 +418,8 @@ function parseTrailer(rest: string): TrailerResult { } function expectNoTrailer(rest: string): TrailerResult { - const r = parseTrailer(rest) + const r = parseTrailer(rest, false) if (!r.ok) return r - // Ignore the read fields; just propagate ok-ness. return { ok: true, sort: null, skip: null, limit: null } } diff --git a/src/shared/api.ts b/src/shared/api.ts index 17445ba..81d1323 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -13,6 +13,8 @@ import type { CreateUserPayload, DatabaseInfo, DatabaseUser, + DeleteByFilterRequest, + DeleteByFilterResponse, DeleteManyRequest, DeleteManyResponse, DeleteOneRequest, @@ -27,9 +29,13 @@ import type { InsertOneRequest, InsertOneResponse, RenameCollectionPayload, + ReplaceByFilterRequest, + ReplaceByFilterResponse, ReplaceOneRequest, ReplaceOneResponse, ServerStats, + UpdateByFilterRequest, + UpdateByFilterResponse, UpdateUserPayload } from './types' import type { Result } from './result' @@ -91,6 +97,9 @@ export type Api = { insertMany: (request: InsertManyRequest) => Promise> deleteOne: (request: DeleteOneRequest) => Promise> deleteMany: (request: DeleteManyRequest) => Promise> + updateByFilter: (request: UpdateByFilterRequest) => Promise> + deleteByFilter: (request: DeleteByFilterRequest) => Promise> + replaceByFilter: (request: ReplaceByFilterRequest) => Promise> } users: { diff --git a/src/shared/schemas.ts b/src/shared/schemas.ts index 127b734..16b4480 100644 --- a/src/shared/schemas.ts +++ b/src/shared/schemas.ts @@ -249,6 +249,39 @@ export const DeleteManyRequestSchema = z }) .strict() +export const UpdateByFilterRequestSchema = z + .object({ + connectionId: z.string().uuid(), + db: dbName, + coll: collName, + filter: ejsonString, + update: documentString, + many: z.boolean(), + upsert: z.boolean() + }) + .strict() + +export const DeleteByFilterRequestSchema = z + .object({ + connectionId: z.string().uuid(), + db: dbName, + coll: collName, + filter: ejsonString, + many: z.boolean() + }) + .strict() + +export const ReplaceByFilterRequestSchema = z + .object({ + connectionId: z.string().uuid(), + db: dbName, + coll: collName, + filter: ejsonString, + replacement: documentString, + upsert: z.boolean() + }) + .strict() + const indexName = z .string() .min(1) diff --git a/src/shared/types.ts b/src/shared/types.ts index 9d1a86e..1165425 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -399,6 +399,59 @@ export type ReplaceOneResponse = { modified: number } +/** + * Filter based write, issued by the shell surface. The id based + * `replaceOne` / `deleteOne` / `deleteMany` requests above stay reserved + * for the document table, which knows the exact documents it touches. + */ +export type UpdateByFilterRequest = { + connectionId: string + db: string + coll: string + /** Canonical-EJSON string of the filter. */ + filter: string + /** Canonical-EJSON string of the update document (must use operators). */ + update: string + /** false = updateOne, true = updateMany. */ + many: boolean + upsert: boolean +} + +export type UpdateByFilterResponse = { + matched: number + modified: number + upsertedId: string | null +} + +export type DeleteByFilterRequest = { + connectionId: string + db: string + coll: string + filter: string + /** false = deleteOne, true = deleteMany. */ + many: boolean +} + +export type DeleteByFilterResponse = { + deletedCount: number +} + +export type ReplaceByFilterRequest = { + connectionId: string + db: string + coll: string + filter: string + /** Canonical-EJSON string of the replacement document. */ + replacement: string + upsert: boolean +} + +export type ReplaceByFilterResponse = { + matched: number + modified: number + upsertedId: string | null +} + export type FindRequest = { connectionId: string db: string