Skip to content

Commit

Permalink
feat(storage): Add support for createMany, updateMany and upsert (red…
Browse files Browse the repository at this point in the history
  • Loading branch information
dac09 authored Aug 30, 2024
1 parent c9beece commit 1f5c396
Show file tree
Hide file tree
Showing 3 changed files with 382 additions and 1 deletion.
9 changes: 9 additions & 0 deletions .changesets/11390.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
- feat(storage): Add support for createMany, updateMany and upsert (#11390) by @dac09

Extends the uploads Prisma client extension with the following:

1. `createMany`: support for bulk creation with automatic cleanup of uploaded files if the operation fails.

2. `updateMany`: bulk update functionality that manages file uploads across multiple records, including removal of old files after successful updates.

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
265 changes: 265 additions & 0 deletions packages/uploads/src/__tests__/queryExtensions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,4 +250,269 @@ describe('Query extensions', () => {
expect(fs.unlink).toHaveBeenCalledWith('im-a-invalid-path')
})
})

describe('upsert', () => {
it('will remove old files and save new ones on upsert, if it exists [UPDATE]', async () => {
const ogDumbo = await prismaClient.dumbo.create({
data: {
firstUpload: '/tmp/oldFirst.txt',
secondUpload: '/tmp/oldSecond.txt',
},
})

const updatedDumbo = await prismaClient.dumbo.upsert({
update: {
firstUpload: '/tmp/newFirst.txt',
},
create: {
// won't be used
firstUpload: 'x',
secondUpload: 'x',
},
where: {
id: ogDumbo.id,
},
})

expect(updatedDumbo.firstUpload).toBe('/tmp/newFirst.txt')
expect(updatedDumbo.secondUpload).toBe('/tmp/oldSecond.txt')
expect(fs.unlink).toHaveBeenCalledOnce()
expect(fs.unlink).toHaveBeenCalledWith('/tmp/oldFirst.txt')
})

it('will create a new record (findOrCreate)', async () => {
const newDumbo = await prismaClient.dumbo.upsert({
create: {
firstUpload: '/tmp/first.txt',
secondUpload: '/bazinga/second.txt',
},
update: {},
where: {
id: 444444444,
},
})

expect(newDumbo.firstUpload).toBe('/tmp/first.txt')
expect(newDumbo.secondUpload).toBe('/bazinga/second.txt')
})

it('will remove processed files if upsert CREATION fails (findOrCreate)', async () => {
// This is essentially findOrCreate, because update is empty
try {
await prismaClient.dumbo.upsert({
create: {
firstUpload: '/tmp/first.txt',
secondUpload: '/bazinga/second.txt',
// @ts-expect-error Checking the error here
id: 'this-is-the-incorrect-type',
},
})
} catch {
expect(fs.unlink).toHaveBeenNthCalledWith(1, '/tmp/first.txt')
expect(fs.unlink).toHaveBeenNthCalledWith(2, '/bazinga/second.txt')
}

expect.assertions(2)
})

it('will remove processed files if upsert UPDATE fails', async () => {
// Bit of a contrived case... why would you ever have different values for update and create...

const ogDumbo = await prismaClient.dumbo.create({
data: {
firstUpload: '/tmp/oldFirst.txt',
secondUpload: '/tmp/oldSecond.txt',
},
})

try {
await prismaClient.dumbo.upsert({
where: {
id: ogDumbo.id,
},
update: {
firstUpload: '/tmp/newFirst.txt',
secondUpload: '/tmp/newSecond.txt',
// @ts-expect-error Intentionally causing an error
id: 'this-should-cause-an-error',
},
create: {
firstUpload: '/tmp/createFirst.txt',
secondUpload: '/tmp/createSecond.txt',
},
})
} catch (error) {
expect(fs.unlink).toHaveBeenCalledTimes(2)
expect(fs.unlink).not.toHaveBeenCalledWith('/tmp/createFirst.txt')
expect(fs.unlink).not.toHaveBeenCalledWith('/tmp/createSecond.txt')
expect(fs.unlink).toHaveBeenNthCalledWith(1, '/tmp/newFirst.txt')
expect(fs.unlink).toHaveBeenNthCalledWith(2, '/tmp/newSecond.txt')
expect(error).toBeDefined()
}

// Verify the original files weren't deleted
const unchangedDumbo = await prismaClient.dumbo.findUnique({
where: { id: ogDumbo.id },
})
expect(unchangedDumbo?.firstUpload).toBe('/tmp/oldFirst.txt')
expect(unchangedDumbo?.secondUpload).toBe('/tmp/oldSecond.txt')

expect.assertions(8)
})
})

describe('createMany', () => {
it('createMany will remove files if all the create fails', async () => {
try {
await prismaClient.dumbo.createMany({
data: [
{
firstUpload: '/one/first.txt',
secondUpload: '/one/second.txt',
// @ts-expect-error Intentional
id: 'break',
},
{
firstUpload: '/two/first.txt',
secondUpload: '/two/second.txt',
// @ts-expect-error Intentional
id: 'break2',
},
],
})
} catch {
expect(fs.unlink).toHaveBeenCalledTimes(4)
expect(fs.unlink).toHaveBeenNthCalledWith(1, '/one/first.txt')
expect(fs.unlink).toHaveBeenNthCalledWith(2, '/one/second.txt')
expect(fs.unlink).toHaveBeenNthCalledWith(3, '/two/first.txt')
expect(fs.unlink).toHaveBeenNthCalledWith(4, '/two/second.txt')
}

expect.assertions(5)
})

it('createMany will remove all files, even if one of them errors', async () => {
try {
await prismaClient.dumbo.createMany({
data: [
// This one is correct, but createMany fails together
// so all the files should be removed!
{
firstUpload: '/one/first.txt',
secondUpload: '/one/second.txt',
id: 9158125,
},
{
firstUpload: '/two/first.txt',
secondUpload: '/two/second.txt',
// @ts-expect-error Intentional
id: 'break2',
},
],
})
} catch {
// This one doesn't actually get created!
expect(
prismaClient.dumbo.findUnique({ where: { id: 9158125 } }),
).resolves.toBeNull()

expect(fs.unlink).toHaveBeenCalledTimes(4)
expect(fs.unlink).toHaveBeenNthCalledWith(1, '/one/first.txt')
expect(fs.unlink).toHaveBeenNthCalledWith(2, '/one/second.txt')
expect(fs.unlink).toHaveBeenNthCalledWith(3, '/two/first.txt')
expect(fs.unlink).toHaveBeenNthCalledWith(4, '/two/second.txt')
}

expect.assertions(6)
})
})

describe('updateMany', () => {
it('will remove old files and save new ones on update, if they exist', async () => {
const ogDumbo1 = await prismaClient.dumbo.create({
data: {
firstUpload: '/FINDME/oldFirst1.txt',
secondUpload: '/FINDME/oldSecond1.txt',
},
})

const ogDumbo2 = await prismaClient.dumbo.create({
data: {
firstUpload: '/FINDME/oldFirst2.txt',
secondUpload: '/FINDME/oldSecond2.txt',
},
})

const updatedDumbos = await prismaClient.dumbo.updateMany({
data: {
firstUpload: '/REPLACED/newFirst.txt',
secondUpload: '/REPLACED/newSecond.txt',
},
where: {
firstUpload: {
contains: 'FINDME',
},
},
})

expect(updatedDumbos.count).toBe(2)

const updatedDumbo1 = await prismaClient.dumbo.findFirstOrThrow({
where: {
id: ogDumbo1.id,
},
})

const updatedDumbo2 = await prismaClient.dumbo.findFirstOrThrow({
where: {
id: ogDumbo2.id,
},
})

// Still performs the update
expect(updatedDumbo1.firstUpload).toBe('/REPLACED/newFirst.txt')
expect(updatedDumbo1.secondUpload).toBe('/REPLACED/newSecond.txt')
expect(updatedDumbo2.firstUpload).toBe('/REPLACED/newFirst.txt')
expect(updatedDumbo2.secondUpload).toBe('/REPLACED/newSecond.txt')

// Then deletes the old files
expect(fs.unlink).toHaveBeenCalledTimes(4)
expect(fs.unlink).toHaveBeenNthCalledWith(1, '/FINDME/oldFirst1.txt')
expect(fs.unlink).toHaveBeenNthCalledWith(2, '/FINDME/oldSecond1.txt')
expect(fs.unlink).toHaveBeenNthCalledWith(3, '/FINDME/oldFirst2.txt')
expect(fs.unlink).toHaveBeenNthCalledWith(4, '/FINDME/oldSecond2.txt')
})

it('will __not__ remove files if the update fails', async () => {
const ogDumbo1 = await prismaClient.dumbo.create({
data: {
firstUpload: '/tmp/oldFirst1.txt',
secondUpload: '/tmp/oldSecond1.txt',
},
})

const ogDumbo2 = await prismaClient.dumbo.create({
data: {
firstUpload: '/tmp/oldFirst2.txt',
secondUpload: '/tmp/oldSecond2.txt',
},
})

const failedUpdatePromise = prismaClient.dumbo.updateMany({
data: {
// @ts-expect-error Intentional
id: 'this-is-the-incorrect-type',
},
where: {
OR: [{ id: ogDumbo1.id }, { id: ogDumbo2.id }],
},
})

// Id is invalid, so the update should fail
await expect(failedUpdatePromise).rejects.toThrowError()

// The old files should NOT be deleted
expect(fs.unlink).not.toHaveBeenCalled()
})
})
})
Loading

0 comments on commit 1f5c396

Please sign in to comment.