diff --git a/packages/db-mongodb/src/index.ts b/packages/db-mongodb/src/index.ts index 911677c14..0e9a4e278 100644 --- a/packages/db-mongodb/src/index.ts +++ b/packages/db-mongodb/src/index.ts @@ -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: ( args: { options?: QueryOptions } & UpdateGlobalVersionArgs, ) => Promise> + updateOne: (args: { options?: QueryOptions } & UpdateOneArgs) => Promise updateVersion: ( args: { options?: QueryOptions } & UpdateVersionArgs, @@ -200,6 +203,7 @@ export function mongooseAdapter({ mongoMemoryServer, sessions: {}, transactionOptions: transactionOptions === false ? undefined : transactionOptions, + updateMany, url, versions: {}, // DatabaseAdapter diff --git a/packages/db-mongodb/src/updateMany.ts b/packages/db-mongodb/src/updateMany.ts new file mode 100644 index 000000000..47bafc86e --- /dev/null +++ b/packages/db-mongodb/src/updateMany.ts @@ -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 +} diff --git a/packages/db-postgres/src/index.ts b/packages/db-postgres/src/index.ts index d08366be5..484a327f6 100644 --- a/packages/db-postgres/src/index.ts +++ b/packages/db-postgres/src/index.ts @@ -33,6 +33,7 @@ import { rollbackTransaction, updateGlobal, updateGlobalVersion, + updateMany, updateOne, updateVersion, } from '@payloadcms/drizzle' @@ -185,6 +186,7 @@ export function postgresAdapter(args: Args): DatabaseAdapterObj rollbackTransaction, updateGlobal, updateGlobalVersion, + updateMany, updateOne, updateVersion, upsert: updateOne, diff --git a/packages/db-sqlite/src/index.ts b/packages/db-sqlite/src/index.ts index 2bd3e0a67..0bedf7545 100644 --- a/packages/db-sqlite/src/index.ts +++ b/packages/db-sqlite/src/index.ts @@ -34,6 +34,7 @@ import { rollbackTransaction, updateGlobal, updateGlobalVersion, + updateMany, updateOne, updateVersion, } from '@payloadcms/drizzle' @@ -120,6 +121,7 @@ export function sqliteAdapter(args: Args): DatabaseAdapterObj { tableNameMap: new Map(), tables: {}, transactionOptions: args.transactionOptions || undefined, + updateMany, versionsSuffix: args.versionsSuffix || '_v', // DatabaseAdapter diff --git a/packages/db-vercel-postgres/src/index.ts b/packages/db-vercel-postgres/src/index.ts index ca8594c99..703db3ce7 100644 --- a/packages/db-vercel-postgres/src/index.ts +++ b/packages/db-vercel-postgres/src/index.ts @@ -33,6 +33,7 @@ import { rollbackTransaction, updateGlobal, updateGlobalVersion, + updateMany, updateOne, updateVersion, } from '@payloadcms/drizzle' @@ -186,6 +187,7 @@ export function vercelPostgresAdapter(args: Args = {}): DatabaseAdapterObj 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 +} diff --git a/packages/payload/src/database/types.ts b/packages/payload/src/database/types.ts index 2cc4db957..78dbf248b 100644 --- a/packages/payload/src/database/types.ts +++ b/packages/payload/src/database/types.ts @@ -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 +export type UpdateManyArgs = { + collection: CollectionSlug + data: Record + draft?: boolean + joins?: JoinQuery + locale?: string + /** + * Additional database adapter specific options to pass to the query + */ + options?: Record + req?: Partial + /** + * If true, returns the updated documents + * + * @default true + */ + returning?: boolean + select?: SelectType + where: Where +} + +export type UpdateMany = (args: UpdateManyArgs) => Promise + export type UpsertArgs = { collection: CollectionSlug data: Record diff --git a/packages/payload/src/index.ts b/packages/payload/src/index.ts index 978181ff1..a657cddaa 100644 --- a/packages/payload/src/index.ts +++ b/packages/payload/src/index.ts @@ -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, diff --git a/test/database/config.ts b/test/database/config.ts index 8342cfcf1..14a086186 100644 --- a/test/database/config.ts +++ b/test/database/config.ts @@ -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'), diff --git a/test/database/int.spec.ts b/test/database/int.spec.ts index bf1987538..02143b096 100644 --- a/test/database/int.spec.ts +++ b/test/database/int.spec.ts @@ -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', () => { diff --git a/test/database/seed.ts b/test/database/seed.ts new file mode 100644 index 000000000..921273e4b --- /dev/null +++ b/test/database/seed.ts @@ -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) +} diff --git a/test/database/shared.ts b/test/database/shared.ts index 0c2fe9967..7600f6654 100644 --- a/test/database/shared.ts +++ b/test/database/shared.ts @@ -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, +]