Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(storage): Add support for createMany, updateMany and upsert #11390

Merged
merged 12 commits into from
Aug 30, 2024
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
Loading