Skip to content

Commit 1f5c396

Browse files
authored
feat(storage): Add support for createMany, updateMany and upsert (#11390)
1 parent c9beece commit 1f5c396

File tree

3 files changed

+382
-1
lines changed

3 files changed

+382
-1
lines changed

.changesets/11390.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
- feat(storage): Add support for createMany, updateMany and upsert (#11390) by @dac09
2+
3+
Extends the uploads Prisma client extension with the following:
4+
5+
1. `createMany`: support for bulk creation with automatic cleanup of uploaded files if the operation fails.
6+
7+
2. `updateMany`: bulk update functionality that manages file uploads across multiple records, including removal of old files after successful updates.
8+
9+
3. `upsert`: determining whether it's an insert or update and managing file uploads accordingly - delete files on creation fail, and replace files on update

packages/uploads/src/__tests__/queryExtensions.test.ts

Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,4 +250,269 @@ describe('Query extensions', () => {
250250
expect(fs.unlink).toHaveBeenCalledWith('im-a-invalid-path')
251251
})
252252
})
253+
254+
describe('upsert', () => {
255+
it('will remove old files and save new ones on upsert, if it exists [UPDATE]', async () => {
256+
const ogDumbo = await prismaClient.dumbo.create({
257+
data: {
258+
firstUpload: '/tmp/oldFirst.txt',
259+
secondUpload: '/tmp/oldSecond.txt',
260+
},
261+
})
262+
263+
const updatedDumbo = await prismaClient.dumbo.upsert({
264+
update: {
265+
firstUpload: '/tmp/newFirst.txt',
266+
},
267+
create: {
268+
// won't be used
269+
firstUpload: 'x',
270+
secondUpload: 'x',
271+
},
272+
where: {
273+
id: ogDumbo.id,
274+
},
275+
})
276+
277+
expect(updatedDumbo.firstUpload).toBe('/tmp/newFirst.txt')
278+
expect(updatedDumbo.secondUpload).toBe('/tmp/oldSecond.txt')
279+
expect(fs.unlink).toHaveBeenCalledOnce()
280+
expect(fs.unlink).toHaveBeenCalledWith('/tmp/oldFirst.txt')
281+
})
282+
283+
it('will create a new record (findOrCreate)', async () => {
284+
const newDumbo = await prismaClient.dumbo.upsert({
285+
create: {
286+
firstUpload: '/tmp/first.txt',
287+
secondUpload: '/bazinga/second.txt',
288+
},
289+
update: {},
290+
where: {
291+
id: 444444444,
292+
},
293+
})
294+
295+
expect(newDumbo.firstUpload).toBe('/tmp/first.txt')
296+
expect(newDumbo.secondUpload).toBe('/bazinga/second.txt')
297+
})
298+
299+
it('will remove processed files if upsert CREATION fails (findOrCreate)', async () => {
300+
// This is essentially findOrCreate, because update is empty
301+
try {
302+
await prismaClient.dumbo.upsert({
303+
create: {
304+
firstUpload: '/tmp/first.txt',
305+
secondUpload: '/bazinga/second.txt',
306+
// @ts-expect-error Checking the error here
307+
id: 'this-is-the-incorrect-type',
308+
},
309+
})
310+
} catch {
311+
expect(fs.unlink).toHaveBeenNthCalledWith(1, '/tmp/first.txt')
312+
expect(fs.unlink).toHaveBeenNthCalledWith(2, '/bazinga/second.txt')
313+
}
314+
315+
expect.assertions(2)
316+
})
317+
318+
it('will remove processed files if upsert UPDATE fails', async () => {
319+
// Bit of a contrived case... why would you ever have different values for update and create...
320+
321+
const ogDumbo = await prismaClient.dumbo.create({
322+
data: {
323+
firstUpload: '/tmp/oldFirst.txt',
324+
secondUpload: '/tmp/oldSecond.txt',
325+
},
326+
})
327+
328+
try {
329+
await prismaClient.dumbo.upsert({
330+
where: {
331+
id: ogDumbo.id,
332+
},
333+
update: {
334+
firstUpload: '/tmp/newFirst.txt',
335+
secondUpload: '/tmp/newSecond.txt',
336+
// @ts-expect-error Intentionally causing an error
337+
id: 'this-should-cause-an-error',
338+
},
339+
create: {
340+
firstUpload: '/tmp/createFirst.txt',
341+
secondUpload: '/tmp/createSecond.txt',
342+
},
343+
})
344+
} catch (error) {
345+
expect(fs.unlink).toHaveBeenCalledTimes(2)
346+
expect(fs.unlink).not.toHaveBeenCalledWith('/tmp/createFirst.txt')
347+
expect(fs.unlink).not.toHaveBeenCalledWith('/tmp/createSecond.txt')
348+
expect(fs.unlink).toHaveBeenNthCalledWith(1, '/tmp/newFirst.txt')
349+
expect(fs.unlink).toHaveBeenNthCalledWith(2, '/tmp/newSecond.txt')
350+
expect(error).toBeDefined()
351+
}
352+
353+
// Verify the original files weren't deleted
354+
const unchangedDumbo = await prismaClient.dumbo.findUnique({
355+
where: { id: ogDumbo.id },
356+
})
357+
expect(unchangedDumbo?.firstUpload).toBe('/tmp/oldFirst.txt')
358+
expect(unchangedDumbo?.secondUpload).toBe('/tmp/oldSecond.txt')
359+
360+
expect.assertions(8)
361+
})
362+
})
363+
364+
describe('createMany', () => {
365+
it('createMany will remove files if all the create fails', async () => {
366+
try {
367+
await prismaClient.dumbo.createMany({
368+
data: [
369+
{
370+
firstUpload: '/one/first.txt',
371+
secondUpload: '/one/second.txt',
372+
// @ts-expect-error Intentional
373+
id: 'break',
374+
},
375+
{
376+
firstUpload: '/two/first.txt',
377+
secondUpload: '/two/second.txt',
378+
// @ts-expect-error Intentional
379+
id: 'break2',
380+
},
381+
],
382+
})
383+
} catch {
384+
expect(fs.unlink).toHaveBeenCalledTimes(4)
385+
expect(fs.unlink).toHaveBeenNthCalledWith(1, '/one/first.txt')
386+
expect(fs.unlink).toHaveBeenNthCalledWith(2, '/one/second.txt')
387+
expect(fs.unlink).toHaveBeenNthCalledWith(3, '/two/first.txt')
388+
expect(fs.unlink).toHaveBeenNthCalledWith(4, '/two/second.txt')
389+
}
390+
391+
expect.assertions(5)
392+
})
393+
394+
it('createMany will remove all files, even if one of them errors', async () => {
395+
try {
396+
await prismaClient.dumbo.createMany({
397+
data: [
398+
// This one is correct, but createMany fails together
399+
// so all the files should be removed!
400+
{
401+
firstUpload: '/one/first.txt',
402+
secondUpload: '/one/second.txt',
403+
id: 9158125,
404+
},
405+
{
406+
firstUpload: '/two/first.txt',
407+
secondUpload: '/two/second.txt',
408+
// @ts-expect-error Intentional
409+
id: 'break2',
410+
},
411+
],
412+
})
413+
} catch {
414+
// This one doesn't actually get created!
415+
expect(
416+
prismaClient.dumbo.findUnique({ where: { id: 9158125 } }),
417+
).resolves.toBeNull()
418+
419+
expect(fs.unlink).toHaveBeenCalledTimes(4)
420+
expect(fs.unlink).toHaveBeenNthCalledWith(1, '/one/first.txt')
421+
expect(fs.unlink).toHaveBeenNthCalledWith(2, '/one/second.txt')
422+
expect(fs.unlink).toHaveBeenNthCalledWith(3, '/two/first.txt')
423+
expect(fs.unlink).toHaveBeenNthCalledWith(4, '/two/second.txt')
424+
}
425+
426+
expect.assertions(6)
427+
})
428+
})
429+
430+
describe('updateMany', () => {
431+
it('will remove old files and save new ones on update, if they exist', async () => {
432+
const ogDumbo1 = await prismaClient.dumbo.create({
433+
data: {
434+
firstUpload: '/FINDME/oldFirst1.txt',
435+
secondUpload: '/FINDME/oldSecond1.txt',
436+
},
437+
})
438+
439+
const ogDumbo2 = await prismaClient.dumbo.create({
440+
data: {
441+
firstUpload: '/FINDME/oldFirst2.txt',
442+
secondUpload: '/FINDME/oldSecond2.txt',
443+
},
444+
})
445+
446+
const updatedDumbos = await prismaClient.dumbo.updateMany({
447+
data: {
448+
firstUpload: '/REPLACED/newFirst.txt',
449+
secondUpload: '/REPLACED/newSecond.txt',
450+
},
451+
where: {
452+
firstUpload: {
453+
contains: 'FINDME',
454+
},
455+
},
456+
})
457+
458+
expect(updatedDumbos.count).toBe(2)
459+
460+
const updatedDumbo1 = await prismaClient.dumbo.findFirstOrThrow({
461+
where: {
462+
id: ogDumbo1.id,
463+
},
464+
})
465+
466+
const updatedDumbo2 = await prismaClient.dumbo.findFirstOrThrow({
467+
where: {
468+
id: ogDumbo2.id,
469+
},
470+
})
471+
472+
// Still performs the update
473+
expect(updatedDumbo1.firstUpload).toBe('/REPLACED/newFirst.txt')
474+
expect(updatedDumbo1.secondUpload).toBe('/REPLACED/newSecond.txt')
475+
expect(updatedDumbo2.firstUpload).toBe('/REPLACED/newFirst.txt')
476+
expect(updatedDumbo2.secondUpload).toBe('/REPLACED/newSecond.txt')
477+
478+
// Then deletes the old files
479+
expect(fs.unlink).toHaveBeenCalledTimes(4)
480+
expect(fs.unlink).toHaveBeenNthCalledWith(1, '/FINDME/oldFirst1.txt')
481+
expect(fs.unlink).toHaveBeenNthCalledWith(2, '/FINDME/oldSecond1.txt')
482+
expect(fs.unlink).toHaveBeenNthCalledWith(3, '/FINDME/oldFirst2.txt')
483+
expect(fs.unlink).toHaveBeenNthCalledWith(4, '/FINDME/oldSecond2.txt')
484+
})
485+
486+
it('will __not__ remove files if the update fails', async () => {
487+
const ogDumbo1 = await prismaClient.dumbo.create({
488+
data: {
489+
firstUpload: '/tmp/oldFirst1.txt',
490+
secondUpload: '/tmp/oldSecond1.txt',
491+
},
492+
})
493+
494+
const ogDumbo2 = await prismaClient.dumbo.create({
495+
data: {
496+
firstUpload: '/tmp/oldFirst2.txt',
497+
secondUpload: '/tmp/oldSecond2.txt',
498+
},
499+
})
500+
501+
const failedUpdatePromise = prismaClient.dumbo.updateMany({
502+
data: {
503+
// @ts-expect-error Intentional
504+
id: 'this-is-the-incorrect-type',
505+
},
506+
where: {
507+
OR: [{ id: ogDumbo1.id }, { id: ogDumbo2.id }],
508+
},
509+
})
510+
511+
// Id is invalid, so the update should fail
512+
await expect(failedUpdatePromise).rejects.toThrowError()
513+
514+
// The old files should NOT be deleted
515+
expect(fs.unlink).not.toHaveBeenCalled()
516+
})
517+
})
253518
})

0 commit comments

Comments
 (0)