Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/main/ipc/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions src/main/ipc/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
DropUserSchema,
FindRequestSchema,
IndexesListSchema,
InsertManyRequestSchema,
InsertOneRequestSchema,
RenameCollectionSchema,
ReorderConnectionsSchema,
Expand Down Expand Up @@ -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))
Expand Down
10 changes: 6 additions & 4 deletions src/main/services/DatabaseService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<CollectionStats> {
Expand Down
10 changes: 10 additions & 0 deletions src/main/services/QueryService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import type {
DocumentEnvelope,
FindRequest,
FindResponse,
InsertManyRequest,
InsertManyResponse,
InsertOneRequest,
InsertOneResponse,
ReplaceOneRequest,
Expand Down Expand Up @@ -111,6 +113,14 @@ export class QueryService {
return { insertedId: toCanonicalString(result.insertedId) }
}

async insertMany(req: InsertManyRequest): Promise<InsertManyResponse> {
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
Expand Down
4 changes: 4 additions & 0 deletions src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import type {
FindRequest,
FindResponse,
IndexInfo,
InsertManyRequest,
InsertManyResponse,
InsertOneRequest,
InsertOneResponse,
RenameCollectionPayload,
Expand Down Expand Up @@ -77,6 +79,8 @@ const api: Api = {
replaceOne: (request: ReplaceOneRequest) =>
invoke<ReplaceOneResponse>('query:replaceOne', request),
insertOne: (request: InsertOneRequest) => invoke<InsertOneResponse>('query:insertOne', request),
insertMany: (request: InsertManyRequest) =>
invoke<InsertManyResponse>('query:insertMany', request),
deleteOne: (request: DeleteOneRequest) => invoke<DeleteOneResponse>('query:deleteOne', request),
deleteMany: (request: DeleteManyRequest) =>
invoke<DeleteManyResponse>('query:deleteMany', request)
Expand Down
41 changes: 30 additions & 11 deletions src/renderer/src/features/document/DocumentEditorDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -36,15 +36,15 @@ const TITLES: Record<EditorMode, string> = {
view: 'View document',
edit: 'Edit document',
duplicate: 'Duplicate document',
insert: 'Insert document'
insert: 'Insert documents'
}

const DESCRIPTIONS: Record<EditorMode, string> = {
view: 'Read-only — BSON types render as ObjectId / ISODate / UUID / NumberLong / NumberDecimal.',
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}'
Expand Down Expand Up @@ -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')
Expand All @@ -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')
Expand All @@ -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()
},
Expand All @@ -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 (
<Dialog open={open} onOpenChange={handleOpenChange}>
Expand Down
2 changes: 1 addition & 1 deletion src/renderer/src/features/explorer/CollectionLeaf.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ export function CollectionLeaf({
<ContextMenuSeparator />
<ContextMenuItem onSelect={() => setDialog('insert')} disabled={type === 'view'}>
<FilePlus2 className="h-4 w-4" />
Insert document
Insert documents
</ContextMenuItem>
<ContextMenuItem onSelect={() => setDialog('indexes')} disabled={type === 'view'}>
<KeyRound className="h-4 w-4" />
Expand Down
4 changes: 4 additions & 0 deletions src/renderer/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ import type {
FindRequest,
FindResponse,
IndexInfo,
InsertManyRequest,
InsertManyResponse,
InsertOneRequest,
InsertOneResponse,
RenameCollectionPayload,
Expand Down Expand Up @@ -102,6 +104,8 @@ export const api = {
unwrap(window.api.query.replaceOne(request)),
insertOne: (request: InsertOneRequest): Promise<InsertOneResponse> =>
unwrap(window.api.query.insertOne(request)),
insertMany: (request: InsertManyRequest): Promise<InsertManyResponse> =>
unwrap(window.api.query.insertMany(request)),
deleteOne: (request: DeleteOneRequest): Promise<DeleteOneResponse> =>
unwrap(window.api.query.deleteOne(request)),
deleteMany: (request: DeleteManyRequest): Promise<DeleteManyResponse> =>
Expand Down
84 changes: 83 additions & 1 deletion src/renderer/src/lib/mongoQueryLang.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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/)
})
})
44 changes: 44 additions & 0 deletions src/renderer/src/lib/mongoQueryLang.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions src/shared/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import type {
FindRequest,
FindResponse,
IndexInfo,
InsertManyRequest,
InsertManyResponse,
InsertOneRequest,
InsertOneResponse,
RenameCollectionPayload,
Expand Down Expand Up @@ -86,6 +88,7 @@ export type Api = {
count: (request: CountRequest) => Promise<Result<CountResponse>>
replaceOne: (request: ReplaceOneRequest) => Promise<Result<ReplaceOneResponse>>
insertOne: (request: InsertOneRequest) => Promise<Result<InsertOneResponse>>
insertMany: (request: InsertManyRequest) => Promise<Result<InsertManyResponse>>
deleteOne: (request: DeleteOneRequest) => Promise<Result<DeleteOneResponse>>
deleteMany: (request: DeleteManyRequest) => Promise<Result<DeleteManyResponse>>
}
Expand Down
9 changes: 9 additions & 0 deletions src/shared/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
Loading
Loading