Skip to content

Commit 2ea8d82

Browse files
ByteExceptionMNycz-labDasDarki
authored
feat: add multi insert and sorting collections from a-z (#18)
Co-authored-by: Nycz-lab <Skybreak00@protonmail.com> Co-authored-by: DasDarki <rageskilln@gmail.com>
1 parent cf4fc8f commit 2ea8d82

14 files changed

Lines changed: 213 additions & 18 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "mongobench",
3-
"version": "1.2.0",
3+
"version": "1.3.0",
44
"private": true,
55
"description": "A modern, dark-mode-first MongoDB GUI.",
66
"author": "ByteExceptionM",

src/main/ipc/channels.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ export const Channels = {
3737
QueryCount: 'query:count',
3838
QueryReplaceOne: 'query:replaceOne',
3939
QueryInsertOne: 'query:insertOne',
40+
QueryInsertMany: 'query:insertMany',
4041
QueryDeleteOne: 'query:deleteOne',
4142
QueryDeleteMany: 'query:deleteMany'
4243
} as const

src/main/ipc/router.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
DropUserSchema,
2222
FindRequestSchema,
2323
IndexesListSchema,
24+
InsertManyRequestSchema,
2425
InsertOneRequestSchema,
2526
RenameCollectionSchema,
2627
ReorderConnectionsSchema,
@@ -212,6 +213,11 @@ export function registerIpcHandlers(services: Services): void {
212213
withResult(InsertOneRequestSchema, (request) => queries.insertOne(request))
213214
)
214215

216+
ipcMain.handle(
217+
Channels.QueryInsertMany,
218+
withResult(InsertManyRequestSchema, (request) => queries.insertMany(request))
219+
)
220+
215221
ipcMain.handle(
216222
Channels.QueryDeleteOne,
217223
withResult(DeleteOneRequestSchema, (request) => queries.deleteOne(request))

src/main/services/DatabaseService.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,12 @@ export class DatabaseService {
3535
.db(db)
3636
.listCollections({}, { nameOnly: true, authorizedCollections: authOnly })
3737
const items = await cursor.toArray()
38-
return items.map((info) => ({
39-
name: info.name as string,
40-
type: (info.type as 'collection' | 'view' | undefined) ?? 'collection'
41-
}))
38+
return items
39+
.map((info) => ({
40+
name: info.name as string,
41+
type: (info.type as 'collection' | 'view' | undefined) ?? 'collection'
42+
}))
43+
.sort((a, b) => a.name.localeCompare(b.name))
4244
}
4345

4446
async collectionStats(connectionId: string, db: string, coll: string): Promise<CollectionStats> {

src/main/services/QueryService.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import type {
1010
DocumentEnvelope,
1111
FindRequest,
1212
FindResponse,
13+
InsertManyRequest,
14+
InsertManyResponse,
1315
InsertOneRequest,
1416
InsertOneResponse,
1517
ReplaceOneRequest,
@@ -111,6 +113,14 @@ export class QueryService {
111113
return { insertedId: toCanonicalString(result.insertedId) }
112114
}
113115

116+
async insertMany(req: InsertManyRequest): Promise<InsertManyResponse> {
117+
const client = this.connections.getClient(req.connectionId)
118+
const coll = client.db(req.db).collection(req.coll)
119+
const documents = req.documents.map(parseDocument)
120+
const result = await coll.insertMany(documents)
121+
return { insertedIds: Object.values(result.insertedIds).map((id) => toCanonicalString(id)) }
122+
}
123+
114124
/**
115125
* Bulk delete by `_id`. No per-document hash check — the renderer
116126
* collects an explicit confirmation before calling this, so a stale

src/preload/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ import type {
2525
FindRequest,
2626
FindResponse,
2727
IndexInfo,
28+
InsertManyRequest,
29+
InsertManyResponse,
2830
InsertOneRequest,
2931
InsertOneResponse,
3032
RenameCollectionPayload,
@@ -77,6 +79,8 @@ const api: Api = {
7779
replaceOne: (request: ReplaceOneRequest) =>
7880
invoke<ReplaceOneResponse>('query:replaceOne', request),
7981
insertOne: (request: InsertOneRequest) => invoke<InsertOneResponse>('query:insertOne', request),
82+
insertMany: (request: InsertManyRequest) =>
83+
invoke<InsertManyResponse>('query:insertMany', request),
8084
deleteOne: (request: DeleteOneRequest) => invoke<DeleteOneResponse>('query:deleteOne', request),
8185
deleteMany: (request: DeleteManyRequest) =>
8286
invoke<DeleteManyResponse>('query:deleteMany', request)

src/renderer/src/features/document/DocumentEditorDialog.tsx

Lines changed: 30 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
} from '@/components/ui/dialog'
1414
import { Button } from '@/components/ui/button'
1515
import { api, ApiError } from '@/lib/api'
16-
import { parseMongoQuery } from '@/lib/mongoQueryLang'
16+
import { parseMongoDocuments, parseMongoQuery } from '@/lib/mongoQueryLang'
1717
import { serializeMongoValue } from '@/lib/mongoQuerySerialize'
1818
import type { DocumentEnvelope, UuidEncoding } from '@shared/types'
1919

@@ -36,15 +36,15 @@ const TITLES: Record<EditorMode, string> = {
3636
view: 'View document',
3737
edit: 'Edit document',
3838
duplicate: 'Duplicate document',
39-
insert: 'Insert document'
39+
insert: 'Insert documents'
4040
}
4141

4242
const DESCRIPTIONS: Record<EditorMode, string> = {
4343
view: 'Read-only — BSON types render as ObjectId / ISODate / UUID / NumberLong / NumberDecimal.',
4444
edit: 'Edit using mongo shell syntax — ObjectId("…"), ISODate("…"), NumberLong("…"), UUID/JUUID, regex literals.',
4545
duplicate: 'A copy with the original _id removed. Save inserts a new document with a fresh _id.',
4646
insert:
47-
'New document — mongo shell syntax. Leave _id out and the server will assign a fresh ObjectId.'
47+
'Multiple documents, one after another newline, comma-separated or JSON array. Leave _id out for a fresh ObjectId.'
4848
}
4949

5050
const INSERT_TEMPLATE = '{\n \n}'
@@ -112,16 +112,26 @@ export function DocumentEditorDialog({
112112
setValue(mode === 'duplicate' ? stripIdShell(rendered, uuidEncoding, timezone) : rendered)
113113
}, [envelope, mode, uuidEncoding, timezone])
114114

115-
const compiled = useMemo(() => {
116-
if (mode === 'view' || mode === null) return { ok: true as const, ejson: '' }
115+
const compiled = useMemo<
116+
{ ok: true; ejson: string; documents: string[] } | { ok: false; error: string }
117+
>(() => {
118+
if (mode === 'view' || mode === null) return { ok: true, ejson: '', documents: [] }
119+
if (mode === 'insert') {
120+
const parsed = parseMongoDocuments(value)
121+
if (!parsed.ok) return { ok: false, error: parsed.error }
122+
if (parsed.documents.length === 0) return { ok: false, error: 'Enter at least one document' }
123+
return { ok: true, ejson: '', documents: parsed.documents }
124+
}
117125
const parsed = parseMongoQuery(value)
118-
if (!parsed.ok) return { ok: false as const, error: parsed.error }
126+
if (!parsed.ok) return { ok: false, error: parsed.error }
119127
if (parsed.value === null || typeof parsed.value !== 'object' || Array.isArray(parsed.value)) {
120-
return { ok: false as const, error: 'Document must be an object' }
128+
return { ok: false, error: 'Document must be an object' }
121129
}
122-
return { ok: true as const, ejson: parsed.ejson }
130+
return { ok: true, ejson: parsed.ejson, documents: [] }
123131
}, [value, mode])
124132

133+
const manyCount = compiled.ok ? compiled.documents.length : 0
134+
125135
const saveMutation = useMutation({
126136
mutationFn: async () => {
127137
if (!mode) throw new Error('No editor mode')
@@ -137,7 +147,10 @@ export function DocumentEditorDialog({
137147
replacement: compiled.ejson
138148
})
139149
}
140-
if (mode === 'duplicate' || mode === 'insert') {
150+
if (mode === 'insert') {
151+
return api.query.insertMany({ connectionId, db, coll, documents: compiled.documents })
152+
}
153+
if (mode === 'duplicate') {
141154
return api.query.insertOne({ connectionId, db, coll, document: compiled.ejson })
142155
}
143156
throw new Error('Cannot save in view mode')
@@ -150,7 +163,7 @@ export function DocumentEditorDialog({
150163
? 'Document updated'
151164
: mode === 'duplicate'
152165
? 'Document duplicated'
153-
: 'Document inserted'
166+
: `Inserted ${manyCount} document${manyCount === 1 ? '' : 's'}`
154167
toast.success(successMessage)
155168
onClose()
156169
},
@@ -172,7 +185,13 @@ export function DocumentEditorDialog({
172185
const parseError = compiled.ok ? null : compiled.error
173186
const isReadOnly = mode === 'view'
174187
const ctaLabel =
175-
mode === 'edit' ? 'Save changes' : mode === 'duplicate' ? 'Insert duplicate' : 'Insert document'
188+
mode === 'edit'
189+
? 'Save changes'
190+
: mode === 'duplicate'
191+
? 'Insert duplicate'
192+
: manyCount > 0
193+
? `Insert ${manyCount} document${manyCount === 1 ? '' : 's'}`
194+
: 'Insert documents'
176195

177196
return (
178197
<Dialog open={open} onOpenChange={handleOpenChange}>

src/renderer/src/features/explorer/CollectionLeaf.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ export function CollectionLeaf({
144144
<ContextMenuSeparator />
145145
<ContextMenuItem onSelect={() => setDialog('insert')} disabled={type === 'view'}>
146146
<FilePlus2 className="h-4 w-4" />
147-
Insert document
147+
Insert documents
148148
</ContextMenuItem>
149149
<ContextMenuItem onSelect={() => setDialog('indexes')} disabled={type === 'view'}>
150150
<KeyRound className="h-4 w-4" />

src/renderer/src/lib/api.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ import type {
2323
FindRequest,
2424
FindResponse,
2525
IndexInfo,
26+
InsertManyRequest,
27+
InsertManyResponse,
2628
InsertOneRequest,
2729
InsertOneResponse,
2830
RenameCollectionPayload,
@@ -102,6 +104,8 @@ export const api = {
102104
unwrap(window.api.query.replaceOne(request)),
103105
insertOne: (request: InsertOneRequest): Promise<InsertOneResponse> =>
104106
unwrap(window.api.query.insertOne(request)),
107+
insertMany: (request: InsertManyRequest): Promise<InsertManyResponse> =>
108+
unwrap(window.api.query.insertMany(request)),
105109
deleteOne: (request: DeleteOneRequest): Promise<DeleteOneResponse> =>
106110
unwrap(window.api.query.deleteOne(request)),
107111
deleteMany: (request: DeleteManyRequest): Promise<DeleteManyResponse> =>

src/renderer/src/lib/mongoQueryLang.test.ts

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, it } from 'vitest'
2-
import { parseMongoQuery } from './mongoQueryLang'
2+
import { parseMongoDocuments, parseMongoQuery } from './mongoQueryLang'
33

44
describe('parseMongoQuery', () => {
55
it('returns empty ejson for blank input', () => {
@@ -143,3 +143,85 @@ describe('parseMongoQuery', () => {
143143
})
144144
})
145145
})
146+
147+
describe('parseMongoDocuments', () => {
148+
it('returns no documents for blank input', () => {
149+
const r = parseMongoDocuments(' ')
150+
expect(r.ok).toBe(true)
151+
if (r.ok) expect(r.documents).toEqual([])
152+
})
153+
154+
it('parses a single document', () => {
155+
const r = parseMongoDocuments('{ name: "ada" }')
156+
expect(r.ok).toBe(true)
157+
if (r.ok) expect(r.documents.map((d) => JSON.parse(d))).toEqual([{ name: 'ada' }])
158+
})
159+
160+
it('parses newline-separated documents without an array', () => {
161+
const r = parseMongoDocuments('{ a: 1 }\n{ b: 2 }\n{ c: 3 }')
162+
expect(r.ok).toBe(true)
163+
if (r.ok) expect(r.documents.map((d) => JSON.parse(d))).toEqual([{ a: 1 }, { b: 2 }, { c: 3 }])
164+
})
165+
166+
it('tolerates comma separators between documents', () => {
167+
const r = parseMongoDocuments('{ a: 1 },\n{ b: 2 },')
168+
expect(r.ok).toBe(true)
169+
if (r.ok) expect(r.documents.map((d) => JSON.parse(d))).toEqual([{ a: 1 }, { b: 2 }])
170+
})
171+
172+
it('treats commas as fully optional separators in any position', () => {
173+
const inputs = [
174+
'{ a: 1 }\n{ b: 2 }',
175+
'{ a: 1 },\n{ b: 2 },',
176+
'{ a: 1 },\n{ b: 2 }',
177+
',{ a: 1 },,{ b: 2 },,'
178+
]
179+
for (const input of inputs) {
180+
const r = parseMongoDocuments(input)
181+
expect(r.ok).toBe(true)
182+
if (r.ok) expect(r.documents.map((d) => JSON.parse(d))).toEqual([{ a: 1 }, { b: 2 }])
183+
}
184+
})
185+
186+
it('rewrites shell helpers inside each document', () => {
187+
const r = parseMongoDocuments(
188+
'{ _id: ObjectId("507f1f77bcf86cd799439011") }\n{ at: ISODate("2024-01-01T00:00:00Z") }'
189+
)
190+
expect(r.ok).toBe(true)
191+
if (r.ok)
192+
expect(r.documents.map((d) => JSON.parse(d))).toEqual([
193+
{ _id: { $oid: '507f1f77bcf86cd799439011' } },
194+
{ at: { $date: '2024-01-01T00:00:00Z' } }
195+
])
196+
})
197+
198+
it('flattens a top-level array of documents', () => {
199+
const r = parseMongoDocuments('[{ a: 1 }, { b: 2 }]')
200+
expect(r.ok).toBe(true)
201+
if (r.ok) expect(r.documents.map((d) => JSON.parse(d))).toEqual([{ a: 1 }, { b: 2 }])
202+
})
203+
204+
it('mixes bare documents and arrays', () => {
205+
const r = parseMongoDocuments('{ a: 1 }\n[{ b: 2 }, { c: 3 }]\n{ d: 4 }')
206+
expect(r.ok).toBe(true)
207+
if (r.ok)
208+
expect(r.documents.map((d) => JSON.parse(d))).toEqual([
209+
{ a: 1 },
210+
{ b: 2 },
211+
{ c: 3 },
212+
{ d: 4 }
213+
])
214+
})
215+
216+
it('rejects a non-object element inside an array', () => {
217+
const r = parseMongoDocuments('[{ a: 1 }, 42]')
218+
expect(r.ok).toBe(false)
219+
if (!r.ok) expect(r.error).toMatch(/must be an object/)
220+
})
221+
222+
it('rejects a non-object document in the sequence', () => {
223+
const r = parseMongoDocuments('{ a: 1 }\n42')
224+
expect(r.ok).toBe(false)
225+
if (!r.ok) expect(r.error).toMatch(/must be an object/)
226+
})
227+
})

0 commit comments

Comments
 (0)