diff --git a/src/main/ipc/channels.ts b/src/main/ipc/channels.ts index 947b0b6..ffc37b0 100644 --- a/src/main/ipc/channels.ts +++ b/src/main/ipc/channels.ts @@ -37,6 +37,7 @@ export const Channels = { QueryCount: 'query:count', QueryReplaceOne: 'query:replaceOne', QueryInsertOne: 'query:insertOne', + QueryInsertMany: 'query:insertMany', QueryDeleteOne: 'query:deleteOne', QueryDeleteMany: 'query:deleteMany' } as const diff --git a/src/main/ipc/router.ts b/src/main/ipc/router.ts index d7223d4..1569945 100644 --- a/src/main/ipc/router.ts +++ b/src/main/ipc/router.ts @@ -21,6 +21,7 @@ import { DropUserSchema, FindRequestSchema, IndexesListSchema, + InsertManyRequestSchema, InsertOneRequestSchema, RenameCollectionSchema, ReorderConnectionsSchema, @@ -212,6 +213,11 @@ export function registerIpcHandlers(services: Services): void { withResult(InsertOneRequestSchema, (request) => queries.insertOne(request)) ) + ipcMain.handle( + Channels.QueryInsertMany, + withResult(InsertManyRequestSchema, (request) => queries.insertMany(request)) + ) + ipcMain.handle( Channels.QueryDeleteOne, withResult(DeleteOneRequestSchema, (request) => queries.deleteOne(request)) diff --git a/src/main/services/DatabaseService.ts b/src/main/services/DatabaseService.ts index 87ba5f6..ca7cd24 100644 --- a/src/main/services/DatabaseService.ts +++ b/src/main/services/DatabaseService.ts @@ -35,10 +35,12 @@ export class DatabaseService { .db(db) .listCollections({}, { nameOnly: true, authorizedCollections: authOnly }) const items = await cursor.toArray() - return items.map((info) => ({ - name: info.name as string, - type: (info.type as 'collection' | 'view' | undefined) ?? 'collection' - })) + return items + .map((info) => ({ + name: info.name as string, + type: (info.type as 'collection' | 'view' | undefined) ?? 'collection' + })) + .sort((a, b) => a.name.localeCompare(b.name)) } async collectionStats(connectionId: string, db: string, coll: string): Promise { diff --git a/src/main/services/QueryService.ts b/src/main/services/QueryService.ts index 9502ca3..31b27b6 100644 --- a/src/main/services/QueryService.ts +++ b/src/main/services/QueryService.ts @@ -10,6 +10,8 @@ import type { DocumentEnvelope, FindRequest, FindResponse, + InsertManyRequest, + InsertManyResponse, InsertOneRequest, InsertOneResponse, ReplaceOneRequest, @@ -111,6 +113,14 @@ export class QueryService { return { insertedId: toCanonicalString(result.insertedId) } } + async insertMany(req: InsertManyRequest): Promise { + const client = this.connections.getClient(req.connectionId) + const coll = client.db(req.db).collection(req.coll) + const documents = req.documents.map(parseDocument) + const result = await coll.insertMany(documents) + return { insertedIds: Object.values(result.insertedIds).map((id) => toCanonicalString(id)) } + } + /** * Bulk delete by `_id`. No per-document hash check — the renderer * collects an explicit confirmation before calling this, so a stale diff --git a/src/preload/index.ts b/src/preload/index.ts index 8426c87..c8fd8e1 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -25,6 +25,8 @@ import type { FindRequest, FindResponse, IndexInfo, + InsertManyRequest, + InsertManyResponse, InsertOneRequest, InsertOneResponse, RenameCollectionPayload, @@ -77,6 +79,8 @@ const api: Api = { replaceOne: (request: ReplaceOneRequest) => invoke('query:replaceOne', request), insertOne: (request: InsertOneRequest) => invoke('query:insertOne', request), + insertMany: (request: InsertManyRequest) => + invoke('query:insertMany', request), deleteOne: (request: DeleteOneRequest) => invoke('query:deleteOne', request), deleteMany: (request: DeleteManyRequest) => invoke('query:deleteMany', request) diff --git a/src/renderer/src/features/document/DocumentEditorDialog.tsx b/src/renderer/src/features/document/DocumentEditorDialog.tsx index d139023..057f074 100644 --- a/src/renderer/src/features/document/DocumentEditorDialog.tsx +++ b/src/renderer/src/features/document/DocumentEditorDialog.tsx @@ -13,7 +13,7 @@ import { } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' import { api, ApiError } from '@/lib/api' -import { parseMongoQuery } from '@/lib/mongoQueryLang' +import { parseMongoDocuments, parseMongoQuery } from '@/lib/mongoQueryLang' import { serializeMongoValue } from '@/lib/mongoQuerySerialize' import type { DocumentEnvelope, UuidEncoding } from '@shared/types' @@ -36,7 +36,7 @@ const TITLES: Record = { view: 'View document', edit: 'Edit document', duplicate: 'Duplicate document', - insert: 'Insert document' + insert: 'Insert documents' } const DESCRIPTIONS: Record = { @@ -44,7 +44,7 @@ const DESCRIPTIONS: Record = { edit: 'Edit using mongo shell syntax — ObjectId("…"), ISODate("…"), NumberLong("…"), UUID/JUUID, regex literals.', duplicate: 'A copy with the original _id removed. Save inserts a new document with a fresh _id.', insert: - 'New document — mongo shell syntax. Leave _id out and the server will assign a fresh ObjectId.' + 'Multiple documents, one after another newline, comma-separated or JSON array. Leave _id out for a fresh ObjectId.' } const INSERT_TEMPLATE = '{\n \n}' @@ -112,16 +112,26 @@ export function DocumentEditorDialog({ setValue(mode === 'duplicate' ? stripIdShell(rendered, uuidEncoding, timezone) : rendered) }, [envelope, mode, uuidEncoding, timezone]) - const compiled = useMemo(() => { - if (mode === 'view' || mode === null) return { ok: true as const, ejson: '' } + const compiled = useMemo< + { ok: true; ejson: string; documents: string[] } | { ok: false; error: string } + >(() => { + if (mode === 'view' || mode === null) return { ok: true, ejson: '', documents: [] } + if (mode === 'insert') { + const parsed = parseMongoDocuments(value) + if (!parsed.ok) return { ok: false, error: parsed.error } + if (parsed.documents.length === 0) return { ok: false, error: 'Enter at least one document' } + return { ok: true, ejson: '', documents: parsed.documents } + } const parsed = parseMongoQuery(value) - if (!parsed.ok) return { ok: false as const, error: parsed.error } + if (!parsed.ok) return { ok: false, error: parsed.error } if (parsed.value === null || typeof parsed.value !== 'object' || Array.isArray(parsed.value)) { - return { ok: false as const, error: 'Document must be an object' } + return { ok: false, error: 'Document must be an object' } } - return { ok: true as const, ejson: parsed.ejson } + return { ok: true, ejson: parsed.ejson, documents: [] } }, [value, mode]) + const manyCount = compiled.ok ? compiled.documents.length : 0 + const saveMutation = useMutation({ mutationFn: async () => { if (!mode) throw new Error('No editor mode') @@ -137,7 +147,10 @@ export function DocumentEditorDialog({ replacement: compiled.ejson }) } - if (mode === 'duplicate' || mode === 'insert') { + if (mode === 'insert') { + return api.query.insertMany({ connectionId, db, coll, documents: compiled.documents }) + } + if (mode === 'duplicate') { return api.query.insertOne({ connectionId, db, coll, document: compiled.ejson }) } throw new Error('Cannot save in view mode') @@ -150,7 +163,7 @@ export function DocumentEditorDialog({ ? 'Document updated' : mode === 'duplicate' ? 'Document duplicated' - : 'Document inserted' + : `Inserted ${manyCount} document${manyCount === 1 ? '' : 's'}` toast.success(successMessage) onClose() }, @@ -172,7 +185,13 @@ export function DocumentEditorDialog({ const parseError = compiled.ok ? null : compiled.error const isReadOnly = mode === 'view' const ctaLabel = - mode === 'edit' ? 'Save changes' : mode === 'duplicate' ? 'Insert duplicate' : 'Insert document' + mode === 'edit' + ? 'Save changes' + : mode === 'duplicate' + ? 'Insert duplicate' + : manyCount > 0 + ? `Insert ${manyCount} document${manyCount === 1 ? '' : 's'}` + : 'Insert documents' return ( diff --git a/src/renderer/src/features/explorer/CollectionLeaf.tsx b/src/renderer/src/features/explorer/CollectionLeaf.tsx index e0460a2..f66a00e 100644 --- a/src/renderer/src/features/explorer/CollectionLeaf.tsx +++ b/src/renderer/src/features/explorer/CollectionLeaf.tsx @@ -144,7 +144,7 @@ export function CollectionLeaf({ setDialog('insert')} disabled={type === 'view'}> - Insert document… + Insert documents… setDialog('indexes')} disabled={type === 'view'}> diff --git a/src/renderer/src/lib/api.ts b/src/renderer/src/lib/api.ts index d39b347..249c3b3 100644 --- a/src/renderer/src/lib/api.ts +++ b/src/renderer/src/lib/api.ts @@ -23,6 +23,8 @@ import type { FindRequest, FindResponse, IndexInfo, + InsertManyRequest, + InsertManyResponse, InsertOneRequest, InsertOneResponse, RenameCollectionPayload, @@ -102,6 +104,8 @@ export const api = { unwrap(window.api.query.replaceOne(request)), insertOne: (request: InsertOneRequest): Promise => unwrap(window.api.query.insertOne(request)), + insertMany: (request: InsertManyRequest): Promise => + unwrap(window.api.query.insertMany(request)), deleteOne: (request: DeleteOneRequest): Promise => unwrap(window.api.query.deleteOne(request)), deleteMany: (request: DeleteManyRequest): Promise => diff --git a/src/renderer/src/lib/mongoQueryLang.test.ts b/src/renderer/src/lib/mongoQueryLang.test.ts index 37c435c..b7d45ec 100644 --- a/src/renderer/src/lib/mongoQueryLang.test.ts +++ b/src/renderer/src/lib/mongoQueryLang.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { parseMongoQuery } from './mongoQueryLang' +import { parseMongoDocuments, parseMongoQuery } from './mongoQueryLang' describe('parseMongoQuery', () => { it('returns empty ejson for blank input', () => { @@ -143,3 +143,85 @@ describe('parseMongoQuery', () => { }) }) }) + +describe('parseMongoDocuments', () => { + it('returns no documents for blank input', () => { + const r = parseMongoDocuments(' ') + expect(r.ok).toBe(true) + if (r.ok) expect(r.documents).toEqual([]) + }) + + it('parses a single document', () => { + const r = parseMongoDocuments('{ name: "ada" }') + expect(r.ok).toBe(true) + if (r.ok) expect(r.documents.map((d) => JSON.parse(d))).toEqual([{ name: 'ada' }]) + }) + + it('parses newline-separated documents without an array', () => { + const r = parseMongoDocuments('{ a: 1 }\n{ b: 2 }\n{ c: 3 }') + expect(r.ok).toBe(true) + if (r.ok) expect(r.documents.map((d) => JSON.parse(d))).toEqual([{ a: 1 }, { b: 2 }, { c: 3 }]) + }) + + it('tolerates comma separators between documents', () => { + const r = parseMongoDocuments('{ a: 1 },\n{ b: 2 },') + expect(r.ok).toBe(true) + if (r.ok) expect(r.documents.map((d) => JSON.parse(d))).toEqual([{ a: 1 }, { b: 2 }]) + }) + + it('treats commas as fully optional separators in any position', () => { + const inputs = [ + '{ a: 1 }\n{ b: 2 }', + '{ a: 1 },\n{ b: 2 },', + '{ a: 1 },\n{ b: 2 }', + ',{ a: 1 },,{ b: 2 },,' + ] + for (const input of inputs) { + const r = parseMongoDocuments(input) + expect(r.ok).toBe(true) + if (r.ok) expect(r.documents.map((d) => JSON.parse(d))).toEqual([{ a: 1 }, { b: 2 }]) + } + }) + + it('rewrites shell helpers inside each document', () => { + const r = parseMongoDocuments( + '{ _id: ObjectId("507f1f77bcf86cd799439011") }\n{ at: ISODate("2024-01-01T00:00:00Z") }' + ) + expect(r.ok).toBe(true) + if (r.ok) + expect(r.documents.map((d) => JSON.parse(d))).toEqual([ + { _id: { $oid: '507f1f77bcf86cd799439011' } }, + { at: { $date: '2024-01-01T00:00:00Z' } } + ]) + }) + + it('flattens a top-level array of documents', () => { + const r = parseMongoDocuments('[{ a: 1 }, { b: 2 }]') + expect(r.ok).toBe(true) + if (r.ok) expect(r.documents.map((d) => JSON.parse(d))).toEqual([{ a: 1 }, { b: 2 }]) + }) + + it('mixes bare documents and arrays', () => { + const r = parseMongoDocuments('{ a: 1 }\n[{ b: 2 }, { c: 3 }]\n{ d: 4 }') + expect(r.ok).toBe(true) + if (r.ok) + expect(r.documents.map((d) => JSON.parse(d))).toEqual([ + { a: 1 }, + { b: 2 }, + { c: 3 }, + { d: 4 } + ]) + }) + + it('rejects a non-object element inside an array', () => { + const r = parseMongoDocuments('[{ a: 1 }, 42]') + expect(r.ok).toBe(false) + if (!r.ok) expect(r.error).toMatch(/must be an object/) + }) + + it('rejects a non-object document in the sequence', () => { + const r = parseMongoDocuments('{ a: 1 }\n42') + expect(r.ok).toBe(false) + if (!r.ok) expect(r.error).toMatch(/must be an object/) + }) +}) diff --git a/src/renderer/src/lib/mongoQueryLang.ts b/src/renderer/src/lib/mongoQueryLang.ts index d9579a9..93a2766 100644 --- a/src/renderer/src/lib/mongoQueryLang.ts +++ b/src/renderer/src/lib/mongoQueryLang.ts @@ -52,6 +52,50 @@ export function parseMongoQuery(input: string): ParseResult { } } +export type DocumentsParseSuccess = { ok: true; documents: string[] } +export type DocumentsParseResult = DocumentsParseSuccess | ParseFailure + +export function parseMongoDocuments(input: string): DocumentsParseResult { + if (input.trim().length === 0) { + return { ok: true, documents: [] } + } + try { + const tokens = tokenize(input) + const parser = new Parser(tokens, input) + const documents: string[] = [] + const skipCommas = (): void => { + let sep = parser.peek() + while (sep && sep.type === 'punct' && sep.value === ',') { + parser.advance() + sep = parser.peek() + } + } + const pushDocument = (value: unknown, offset: number): void => { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new ParseError('Each document must be an object', offset) + } + documents.push(JSON.stringify(value)) + } + skipCommas() + while (parser.peek()) { + const offset = parser.peek()!.offset + const value = parser.parseValue() + if (Array.isArray(value)) { + for (const item of value) pushDocument(item, offset) + } else { + pushDocument(value, offset) + } + skipCommas() + } + return { ok: true, documents } + } catch (e) { + if (e instanceof ParseError) { + return { ok: false, error: e.message, offset: e.offset } + } + return { ok: false, error: e instanceof Error ? e.message : String(e), offset: 0 } + } +} + class ParseError extends Error { constructor( message: string, diff --git a/src/shared/api.ts b/src/shared/api.ts index 5ff3a79..17445ba 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -22,6 +22,8 @@ import type { FindRequest, FindResponse, IndexInfo, + InsertManyRequest, + InsertManyResponse, InsertOneRequest, InsertOneResponse, RenameCollectionPayload, @@ -86,6 +88,7 @@ export type Api = { count: (request: CountRequest) => Promise> replaceOne: (request: ReplaceOneRequest) => Promise> insertOne: (request: InsertOneRequest) => Promise> + insertMany: (request: InsertManyRequest) => Promise> deleteOne: (request: DeleteOneRequest) => Promise> deleteMany: (request: DeleteManyRequest) => Promise> } diff --git a/src/shared/schemas.ts b/src/shared/schemas.ts index 338ef90..127b734 100644 --- a/src/shared/schemas.ts +++ b/src/shared/schemas.ts @@ -221,6 +221,15 @@ export const InsertOneRequestSchema = z }) .strict() +export const InsertManyRequestSchema = z + .object({ + connectionId: z.string().uuid(), + db: dbName, + coll: collName, + documents: z.array(documentString).min(1).max(10_000) + }) + .strict() + export const DeleteOneRequestSchema = z .object({ connectionId: z.string().uuid(), diff --git a/src/shared/types.ts b/src/shared/types.ts index e154a7b..9d1a86e 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -354,6 +354,17 @@ export type InsertOneResponse = { insertedId: string } +export type InsertManyRequest = { + connectionId: string + db: string + coll: string + documents: string[] +} + +export type InsertManyResponse = { + insertedIds: string[] +} + export type DeleteOneRequest = { connectionId: string db: string