feat(db-*): add updateMany method to database adapter (#11441)

This PR adds a new `payload.db.updateMany` method, which is a more performant way to update multiple documents compared to using `payload.update`.
This commit is contained in:
Alessio Gravili
2025-02-27 20:30:17 -07:00
committed by GitHub
parent 3709950d50
commit 41c7413f59
13 changed files with 370 additions and 18 deletions

View File

@@ -16,6 +16,7 @@ import type {
TypeWithVersion,
UpdateGlobalArgs,
UpdateGlobalVersionArgs,
UpdateManyArgs,
UpdateOneArgs,
UpdateVersionArgs,
} from 'payload'
@@ -53,6 +54,7 @@ import { commitTransaction } from './transactions/commitTransaction.js'
import { rollbackTransaction } from './transactions/rollbackTransaction.js'
import { updateGlobal } from './updateGlobal.js'
import { updateGlobalVersion } from './updateGlobalVersion.js'
import { updateMany } from './updateMany.js'
import { updateOne } from './updateOne.js'
import { updateVersion } from './updateVersion.js'
import { upsert } from './upsert.js'
@@ -160,6 +162,7 @@ declare module 'payload' {
updateGlobalVersion: <T extends TypeWithID = TypeWithID>(
args: { options?: QueryOptions } & UpdateGlobalVersionArgs<T>,
) => Promise<TypeWithVersion<T>>
updateOne: (args: { options?: QueryOptions } & UpdateOneArgs) => Promise<Document>
updateVersion: <T extends TypeWithID = TypeWithID>(
args: { options?: QueryOptions } & UpdateVersionArgs<T>,
@@ -200,6 +203,7 @@ export function mongooseAdapter({
mongoMemoryServer,
sessions: {},
transactionOptions: transactionOptions === false ? undefined : transactionOptions,
updateMany,
url,
versions: {},
// DatabaseAdapter

View File

@@ -0,0 +1,61 @@
import type { MongooseUpdateQueryOptions } from 'mongoose'
import type { UpdateMany } from 'payload'
import type { MongooseAdapter } from './index.js'
import { buildQuery } from './queries/buildQuery.js'
import { buildProjectionFromSelect } from './utilities/buildProjectionFromSelect.js'
import { getSession } from './utilities/getSession.js'
import { handleError } from './utilities/handleError.js'
import { transform } from './utilities/transform.js'
export const updateMany: UpdateMany = async function updateMany(
this: MongooseAdapter,
{ collection, data, locale, options: optionsArgs = {}, req, returning, select, where },
) {
const Model = this.collections[collection]
const fields = this.payload.collections[collection].config.fields
const options: MongooseUpdateQueryOptions = {
...optionsArgs,
lean: true,
new: true,
projection: buildProjectionFromSelect({
adapter: this,
fields: this.payload.collections[collection].config.flattenedFields,
select,
}),
session: await getSession(this, req),
}
const query = await buildQuery({
adapter: this,
collectionSlug: collection,
fields: this.payload.collections[collection].config.flattenedFields,
locale,
where,
})
transform({ adapter: this, data, fields, operation: 'write' })
try {
await Model.updateMany(query, data, options)
} catch (error) {
handleError({ collection, error, req })
}
if (returning === false) {
return null
}
const result = await Model.find(query, {}, options)
transform({
adapter: this,
data: result,
fields,
operation: 'read',
})
return result
}

View File

@@ -33,6 +33,7 @@ import {
rollbackTransaction,
updateGlobal,
updateGlobalVersion,
updateMany,
updateOne,
updateVersion,
} from '@payloadcms/drizzle'
@@ -185,6 +186,7 @@ export function postgresAdapter(args: Args): DatabaseAdapterObj<PostgresAdapter>
rollbackTransaction,
updateGlobal,
updateGlobalVersion,
updateMany,
updateOne,
updateVersion,
upsert: updateOne,

View File

@@ -34,6 +34,7 @@ import {
rollbackTransaction,
updateGlobal,
updateGlobalVersion,
updateMany,
updateOne,
updateVersion,
} from '@payloadcms/drizzle'
@@ -120,6 +121,7 @@ export function sqliteAdapter(args: Args): DatabaseAdapterObj<SQLiteAdapter> {
tableNameMap: new Map<string, string>(),
tables: {},
transactionOptions: args.transactionOptions || undefined,
updateMany,
versionsSuffix: args.versionsSuffix || '_v',
// DatabaseAdapter

View File

@@ -33,6 +33,7 @@ import {
rollbackTransaction,
updateGlobal,
updateGlobalVersion,
updateMany,
updateOne,
updateVersion,
} from '@payloadcms/drizzle'
@@ -186,6 +187,7 @@ export function vercelPostgresAdapter(args: Args = {}): DatabaseAdapterObj<Verce
rollbackTransaction,
updateGlobal,
updateGlobalVersion,
updateMany,
updateOne,
updateVersion,
upsert: updateOne,

View File

@@ -33,6 +33,7 @@ export { commitTransaction } from './transactions/commitTransaction.js'
export { rollbackTransaction } from './transactions/rollbackTransaction.js'
export { updateGlobal } from './updateGlobal.js'
export { updateGlobalVersion } from './updateGlobalVersion.js'
export { updateMany } from './updateMany.js'
export { updateOne } from './updateOne.js'
export { updateVersion } from './updateVersion.js'
export { upsertRow } from './upsertRow/index.js'

View File

@@ -0,0 +1,97 @@
import type { LibSQLDatabase } from 'drizzle-orm/libsql'
import type { UpdateMany } from 'payload'
import toSnakeCase from 'to-snake-case'
import type { DrizzleAdapter } from './types.js'
import buildQuery from './queries/buildQuery.js'
import { selectDistinct } from './queries/selectDistinct.js'
import { upsertRow } from './upsertRow/index.js'
import { getTransaction } from './utilities/getTransaction.js'
export const updateMany: UpdateMany = async function updateMany(
this: DrizzleAdapter,
{
collection: collectionSlug,
data,
joins: joinQuery,
locale,
req,
returning,
select,
where: whereToUse,
},
) {
const db = await getTransaction(this, req)
const collection = this.payload.collections[collectionSlug].config
const tableName = this.tableNameMap.get(toSnakeCase(collection.slug))
const { joins, selectFields, where } = buildQuery({
adapter: this,
fields: collection.flattenedFields,
locale,
tableName,
where: whereToUse,
})
let idsToUpdate: (number | string)[] = []
const selectDistinctResult = await selectDistinct({
adapter: this,
db,
joins,
selectFields,
tableName,
where,
})
if (selectDistinctResult?.[0]?.id) {
idsToUpdate = selectDistinctResult?.map((doc) => doc.id)
// If id wasn't passed but `where` without any joins, retrieve it with findFirst
} else if (whereToUse && !joins.length) {
const _db = db as LibSQLDatabase
const table = this.tables[tableName]
const docsToUpdate = await _db
.select({
id: table.id,
})
.from(table)
.where(where)
idsToUpdate = docsToUpdate?.map((doc) => doc.id)
}
if (!idsToUpdate.length) {
return []
}
const results = []
// TODO: We need to batch this to reduce the amount of db calls. This can get very slow if we are updating a lot of rows.
for (const idToUpdate of idsToUpdate) {
const result = await upsertRow({
id: idToUpdate,
adapter: this,
data,
db,
fields: collection.flattenedFields,
ignoreResult: returning === false,
joinQuery,
operation: 'update',
req,
select,
tableName,
})
results.push(result)
}
if (returning === false) {
return null
}
return results
}

View File

@@ -145,6 +145,8 @@ export interface BaseDatabaseAdapter {
updateGlobalVersion: UpdateGlobalVersion
updateMany: UpdateMany
updateOne: UpdateOne
updateVersion: UpdateVersion
@@ -510,6 +512,29 @@ export type UpdateOneArgs = {
*/
export type UpdateOne = (args: UpdateOneArgs) => Promise<Document>
export type UpdateManyArgs = {
collection: CollectionSlug
data: Record<string, unknown>
draft?: boolean
joins?: JoinQuery
locale?: string
/**
* Additional database adapter specific options to pass to the query
*/
options?: Record<string, unknown>
req?: Partial<PayloadRequest>
/**
* If true, returns the updated documents
*
* @default true
*/
returning?: boolean
select?: SelectType
where: Where
}
export type UpdateMany = (args: UpdateManyArgs) => Promise<Document[] | null>
export type UpsertArgs = {
collection: CollectionSlug
data: Record<string, unknown>

View File

@@ -1112,6 +1112,7 @@ export type {
Connect,
Count,
CountArgs,
CountGlobalVersionArgs,
CountGlobalVersions,
CountVersions,
Create,
@@ -1156,6 +1157,8 @@ export type {
UpdateGlobalArgs,
UpdateGlobalVersion,
UpdateGlobalVersionArgs,
UpdateMany,
UpdateManyArgs,
UpdateOne,
UpdateOneArgs,
UpdateVersion,

View File

@@ -8,7 +8,21 @@ import { v4 as uuid } from 'uuid'
import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
import { devUser } from '../credentials.js'
import { errorOnUnnamedFieldsSlug, postsSlug } from './shared.js'
import { seed } from './seed.js'
import {
customIDsSlug,
customSchemaSlug,
defaultValuesSlug,
errorOnUnnamedFieldsSlug,
fakeCustomIDsSlug,
fieldsPersistanceSlug,
pgMigrationSlug,
placesSlug,
postsSlug,
relationASlug,
relationBSlug,
relationshipsMigrationSlug,
} from './shared.js'
const defaultValueField: TextField = {
name: 'defaultValue',
@@ -183,7 +197,7 @@ export default buildConfigWithDefaults({
],
},
{
slug: 'default-values',
slug: defaultValuesSlug,
fields: [
{
name: 'title',
@@ -222,7 +236,7 @@ export default buildConfigWithDefaults({
],
},
{
slug: 'relation-a',
slug: relationASlug,
fields: [
{
name: 'title',
@@ -239,7 +253,7 @@ export default buildConfigWithDefaults({
},
},
{
slug: 'relation-b',
slug: relationBSlug,
fields: [
{
name: 'title',
@@ -261,7 +275,7 @@ export default buildConfigWithDefaults({
},
},
{
slug: 'pg-migrations',
slug: pgMigrationSlug,
fields: [
{
name: 'relation1',
@@ -329,7 +343,7 @@ export default buildConfigWithDefaults({
versions: true,
},
{
slug: 'custom-schema',
slug: customSchemaSlug,
dbName: 'customs',
fields: [
{
@@ -404,7 +418,7 @@ export default buildConfigWithDefaults({
},
},
{
slug: 'places',
slug: placesSlug,
fields: [
{
name: 'country',
@@ -417,7 +431,7 @@ export default buildConfigWithDefaults({
],
},
{
slug: 'fields-persistance',
slug: fieldsPersistanceSlug,
fields: [
{
name: 'text',
@@ -475,7 +489,7 @@ export default buildConfigWithDefaults({
],
},
{
slug: 'custom-ids',
slug: customIDsSlug,
fields: [
{
name: 'id',
@@ -502,7 +516,7 @@ export default buildConfigWithDefaults({
versions: { drafts: true },
},
{
slug: 'fake-custom-ids',
slug: fakeCustomIDsSlug,
fields: [
{
name: 'title',
@@ -535,7 +549,7 @@ export default buildConfigWithDefaults({
],
},
{
slug: 'relationships-migration',
slug: relationshipsMigrationSlug,
fields: [
{
type: 'relationship',
@@ -587,13 +601,9 @@ export default buildConfigWithDefaults({
locales: ['en', 'es'],
},
onInit: async (payload) => {
await payload.create({
collection: 'users',
data: {
email: devUser.email,
password: devUser.password,
},
})
if (process.env.SEED_IN_CONFIG_ONINIT !== 'false') {
await seed(payload)
}
},
typescript: {
outputFile: path.resolve(dirname, 'payload-types.ts'),

View File

@@ -28,6 +28,7 @@ import { devUser } from '../credentials.js'
import { initPayloadInt } from '../helpers/initPayloadInt.js'
import { isMongoose } from '../helpers/isMongoose.js'
import removeFiles from '../helpers/removeFiles.js'
import { seed } from './seed.js'
import { errorOnUnnamedFieldsSlug, postsSlug } from './shared.js'
const filename = fileURLToPath(import.meta.url)
@@ -43,9 +44,17 @@ process.env.PAYLOAD_CONFIG_PATH = path.join(dirname, 'config.ts')
describe('database', () => {
beforeAll(async () => {
process.env.SEED_IN_CONFIG_ONINIT = 'false' // Makes it so the payload config onInit seed is not run. Otherwise, the seed would be run unnecessarily twice for the initial test run - once for beforeEach and once for onInit
;({ payload, restClient } = await initPayloadInt(dirname))
payload.db.migrationDir = path.join(dirname, './migrations')
await seed(payload)
await restClient.login({
slug: 'users',
credentials: devUser,
})
const loginResult = await payload.login({
collection: 'users',
data: {
@@ -794,6 +803,7 @@ describe('database', () => {
data: {
title,
},
depth: 0,
disableTransaction: true,
})
})
@@ -876,6 +886,80 @@ describe('database', () => {
expect(result.point).toEqual([5, 10])
})
it('ensure updateMany updates all docs and respects where query', async () => {
await payload.db.deleteMany({
collection: postsSlug,
where: {
id: {
exists: true,
},
},
})
await payload.create({
collection: postsSlug,
data: {
title: 'notupdated',
},
})
// Create 5 posts
for (let i = 0; i < 5; i++) {
await payload.create({
collection: postsSlug,
data: {
title: `v1 ${i}`,
},
})
}
const result = await payload.db.updateMany({
collection: postsSlug,
data: {
title: 'updated',
},
where: {
title: {
not_equals: 'notupdated',
},
},
})
expect(result?.length).toBe(5)
expect(result?.[0]?.title).toBe('updated')
expect(result?.[4]?.title).toBe('updated')
// Ensure all posts minus the one we don't want updated are updated
const { docs } = await payload.find({
collection: postsSlug,
depth: 0,
pagination: false,
where: {
title: {
equals: 'updated',
},
},
})
expect(docs).toHaveLength(5)
expect(docs?.[0]?.title).toBe('updated')
expect(docs?.[4]?.title).toBe('updated')
const { docs: notUpdatedDocs } = await payload.find({
collection: postsSlug,
depth: 0,
pagination: false,
where: {
title: {
not_equals: 'updated',
},
},
})
expect(notUpdatedDocs).toHaveLength(1)
expect(notUpdatedDocs?.[0]?.title).toBe('notupdated')
})
})
describe('Error Handler', () => {

26
test/database/seed.ts Normal file
View File

@@ -0,0 +1,26 @@
import type { Payload } from 'payload'
import path from 'path'
import { getFileByPath } from 'payload'
import { fileURLToPath } from 'url'
import { devUser } from '../credentials.js'
import { seedDB } from '../helpers/seed.js'
import { collectionSlugs } from './shared.js'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
export const _seed = async (_payload: Payload) => {
await _payload.create({
collection: 'users',
data: {
email: devUser.email,
password: devUser.password,
},
})
}
export async function seed(_payload: Payload) {
return await _seed(_payload)
}

View File

@@ -1,2 +1,37 @@
export const postsSlug = 'posts'
export const errorOnUnnamedFieldsSlug = 'error-on-unnamed-fields'
export const defaultValuesSlug = 'default-values'
export const relationASlug = 'relation-a'
export const relationBSlug = 'relation-b'
export const pgMigrationSlug = 'pg-migrations'
export const customSchemaSlug = 'custom-schema'
export const placesSlug = 'places'
export const fieldsPersistanceSlug = 'fields-persistance'
export const customIDsSlug = 'custom-ids'
export const fakeCustomIDsSlug = 'fake-custom-ids'
export const relationshipsMigrationSlug = 'relationships-migration'
export const collectionSlugs = [
postsSlug,
errorOnUnnamedFieldsSlug,
defaultValuesSlug,
relationASlug,
relationBSlug,
pgMigrationSlug,
customSchemaSlug,
placesSlug,
fieldsPersistanceSlug,
customIDsSlug,
fakeCustomIDsSlug,
relationshipsMigrationSlug,
]