Skip to content

Commit c2dc42b

Browse files
committed
feat: add multi insert and sorting collections from a-z
1 parent 5ec356b commit c2dc42b

13 files changed

Lines changed: 233 additions & 21 deletions

File tree

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: 45 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,11 @@ 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

20-
export type EditorMode = 'view' | 'edit' | 'duplicate' | 'insert'
20+
export type EditorMode = 'view' | 'edit' | 'duplicate' | 'insert' | 'insert-many'
2121

2222
type Props = {
2323
mode: EditorMode | null
@@ -36,18 +36,22 @@ const TITLES: Record<EditorMode, string> = {
3636
view: 'View document',
3737
edit: 'Edit document',
3838
duplicate: 'Duplicate document',
39-
insert: 'Insert document'
39+
insert: 'Insert document',
40+
'insert-many': 'Insert documents'
4041
}
4142

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

5053
const INSERT_TEMPLATE = '{\n \n}'
54+
const INSERT_MANY_TEMPLATE = '{\n \n}\n{\n \n}'
5155

5256
/**
5357
* Read the canonical EJSON document carried by an envelope, then re-render
@@ -95,7 +99,7 @@ export function DocumentEditorDialog({
9599
timezone,
96100
onClose
97101
}: Props) {
98-
const open = mode !== null && (mode === 'insert' || envelope !== null)
102+
const open = mode !== null && (mode === 'insert' || mode === 'insert-many' || envelope !== null)
99103
const [value, setValue] = useState('')
100104
const [serverError, setServerError] = useState<string | null>(null)
101105
const queryClient = useQueryClient()
@@ -107,21 +111,35 @@ export function DocumentEditorDialog({
107111
setValue(INSERT_TEMPLATE)
108112
return
109113
}
114+
if (mode === 'insert-many') {
115+
setValue(INSERT_MANY_TEMPLATE)
116+
return
117+
}
110118
if (!envelope) return
111119
const rendered = shellRenderOf(envelope, uuidEncoding, timezone)
112120
setValue(mode === 'duplicate' ? stripIdShell(rendered, uuidEncoding, timezone) : rendered)
113121
}, [envelope, mode, uuidEncoding, timezone])
114122

115-
const compiled = useMemo(() => {
116-
if (mode === 'view' || mode === null) return { ok: true as const, ejson: '' }
123+
const compiled = useMemo<
124+
{ ok: true; ejson: string; documents: string[] } | { ok: false; error: string }
125+
>(() => {
126+
if (mode === 'view' || mode === null) return { ok: true, ejson: '', documents: [] }
127+
if (mode === 'insert-many') {
128+
const parsed = parseMongoDocuments(value)
129+
if (!parsed.ok) return { ok: false, error: parsed.error }
130+
if (parsed.documents.length === 0) return { ok: false, error: 'Enter at least one document' }
131+
return { ok: true, ejson: '', documents: parsed.documents }
132+
}
117133
const parsed = parseMongoQuery(value)
118-
if (!parsed.ok) return { ok: false as const, error: parsed.error }
134+
if (!parsed.ok) return { ok: false, error: parsed.error }
119135
if (parsed.value === null || typeof parsed.value !== 'object' || Array.isArray(parsed.value)) {
120-
return { ok: false as const, error: 'Document must be an object' }
136+
return { ok: false, error: 'Document must be an object' }
121137
}
122-
return { ok: true as const, ejson: parsed.ejson }
138+
return { ok: true, ejson: parsed.ejson, documents: [] }
123139
}, [value, mode])
124140

141+
const manyCount = compiled.ok ? compiled.documents.length : 0
142+
125143
const saveMutation = useMutation({
126144
mutationFn: async () => {
127145
if (!mode) throw new Error('No editor mode')
@@ -137,6 +155,9 @@ export function DocumentEditorDialog({
137155
replacement: compiled.ejson
138156
})
139157
}
158+
if (mode === 'insert-many') {
159+
return api.query.insertMany({ connectionId, db, coll, documents: compiled.documents })
160+
}
140161
if (mode === 'duplicate' || mode === 'insert') {
141162
return api.query.insertOne({ connectionId, db, coll, document: compiled.ejson })
142163
}
@@ -150,7 +171,9 @@ export function DocumentEditorDialog({
150171
? 'Document updated'
151172
: mode === 'duplicate'
152173
? 'Document duplicated'
153-
: 'Document inserted'
174+
: mode === 'insert-many'
175+
? `Inserted ${manyCount} document${manyCount === 1 ? '' : 's'}`
176+
: 'Document inserted'
154177
toast.success(successMessage)
155178
onClose()
156179
},
@@ -167,12 +190,20 @@ export function DocumentEditorDialog({
167190
if (!mode) return null
168191

169192
// Insert needs neither envelope nor _id; the others can't render without one.
170-
if (mode !== 'insert' && !envelope) return null
193+
if (mode !== 'insert' && mode !== 'insert-many' && !envelope) return null
171194

172195
const parseError = compiled.ok ? null : compiled.error
173196
const isReadOnly = mode === 'view'
174197
const ctaLabel =
175-
mode === 'edit' ? 'Save changes' : mode === 'duplicate' ? 'Insert duplicate' : 'Insert document'
198+
mode === 'edit'
199+
? 'Save changes'
200+
: mode === 'duplicate'
201+
? 'Insert duplicate'
202+
: mode === 'insert-many'
203+
? manyCount > 0
204+
? `Insert ${manyCount} document${manyCount === 1 ? '' : 's'}`
205+
: 'Insert documents'
206+
: 'Insert document'
176207

177208
return (
178209
<Dialog open={open} onOpenChange={handleOpenChange}>
@@ -204,7 +235,7 @@ export function DocumentEditorDialog({
204235
}
205236
>
206237
<Editor
207-
key={mode === 'insert' ? 'insert' : `${envelope?.id}::${mode}`}
238+
key={mode === 'insert' || mode === 'insert-many' ? mode : `${envelope?.id}::${mode}`}
208239
height="100%"
209240
width="100%"
210241
value={value}

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

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
ClipboardCopy,
66
Eye,
77
FilePlus2,
8+
Files,
89
Info,
910
KeyRound,
1011
Loader2,
@@ -40,7 +41,7 @@ import { RenameCollectionDialog } from '@/features/collection/RenameCollectionDi
4041
import { DocumentEditorDialog } from '@/features/document/DocumentEditorDialog'
4142
import { useQuery } from '@tanstack/react-query'
4243

43-
type DialogState = 'info' | 'rename' | 'drop' | 'indexes' | 'insert' | null
44+
type DialogState = 'info' | 'rename' | 'drop' | 'indexes' | 'insert' | 'insert-many' | null
4445

4546
export function CollectionLeaf({
4647
connectionId,
@@ -146,6 +147,10 @@ export function CollectionLeaf({
146147
<FilePlus2 className="h-4 w-4" />
147148
Insert document…
148149
</ContextMenuItem>
150+
<ContextMenuItem onSelect={() => setDialog('insert-many')} disabled={type === 'view'}>
151+
<Files className="h-4 w-4" />
152+
Insert documents…
153+
</ContextMenuItem>
149154
<ContextMenuItem onSelect={() => setDialog('indexes')} disabled={type === 'view'}>
150155
<KeyRound className="h-4 w-4" />
151156
Indexes…
@@ -182,7 +187,7 @@ export function CollectionLeaf({
182187
/>
183188

184189
<DocumentEditorDialog
185-
mode={dialog === 'insert' ? 'insert' : null}
190+
mode={dialog === 'insert' ? 'insert' : dialog === 'insert-many' ? 'insert-many' : null}
186191
envelope={null}
187192
connectionId={connectionId}
188193
db={db}

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)