From 841bf891d074fba69560d5200fcba210590e1de6 Mon Sep 17 00:00:00 2001 From: Sasha <64744993+r1tsuu@users.noreply.github.com> Date: Wed, 16 Jul 2025 07:53:45 +0300 Subject: [PATCH] feat: atomic number field updates (#13118) Based on https://github.com/payloadcms/payload/pull/13060 which should be merged first This PR adds ability to update number fields atomically, which could be important with parallel writes. For now we support this only via `payload.db.updateOne`. For example: ```js // increment by 10 const res = await payload.db.updateOne({ data: { number: { $inc: 10, }, }, collection: 'posts', where: { id: { equals: post.id } }, }) // decrement by 3 const res2 = await payload.db.updateOne({ data: { number: { $inc: -3, }, }, collection: 'posts', where: { id: { equals: post.id } }, }) ``` --- packages/db-mongodb/src/updateOne.ts | 13 ++++++--- .../db-mongodb/src/utilities/transform.ts | 20 +++++++++++-- packages/drizzle/src/transform/write/index.ts | 3 ++ .../src/transform/write/traverseFields.ts | 22 +++++++++++++-- packages/drizzle/src/updateOne.ts | 1 + packages/drizzle/src/upsertRow/index.ts | 1 + .../payload/src/utilities/traverseFields.ts | 28 +++++++++++++++++-- test/database/int.spec.ts | 28 +++++++++++++++++++ 8 files changed, 106 insertions(+), 10 deletions(-) diff --git a/packages/db-mongodb/src/updateOne.ts b/packages/db-mongodb/src/updateOne.ts index 3fd4a0a516..20816512ad 100644 --- a/packages/db-mongodb/src/updateOne.ts +++ b/packages/db-mongodb/src/updateOne.ts @@ -1,4 +1,4 @@ -import type { MongooseUpdateQueryOptions } from 'mongoose' +import type { MongooseUpdateQueryOptions, UpdateQuery } from 'mongoose' import type { UpdateOne } from 'payload' import type { MongooseAdapter } from './index.js' @@ -50,15 +50,20 @@ export const updateOne: UpdateOne = async function updateOne( let result - transform({ adapter: this, data, fields, operation: 'write' }) + const $inc: Record = {} + let updateData: UpdateQuery = data + transform({ $inc, adapter: this, data, fields, operation: 'write' }) + if (Object.keys($inc).length) { + updateData = { $inc, $set: updateData } + } try { if (returning === false) { - await Model.updateOne(query, data, options) + await Model.updateOne(query, updateData, options) transform({ adapter: this, data, fields, operation: 'read' }) return null } else { - result = await Model.findOneAndUpdate(query, data, options) + result = await Model.findOneAndUpdate(query, updateData, options) } } catch (error) { handleError({ collection: collectionSlug, error, req }) diff --git a/packages/db-mongodb/src/utilities/transform.ts b/packages/db-mongodb/src/utilities/transform.ts index 74b6e7b93a..7318c29cee 100644 --- a/packages/db-mongodb/src/utilities/transform.ts +++ b/packages/db-mongodb/src/utilities/transform.ts @@ -208,6 +208,7 @@ const sanitizeDate = ({ } type Args = { + $inc?: Record /** instance of the adapter */ adapter: MongooseAdapter /** data to transform, can be an array of documents or a single document */ @@ -396,6 +397,7 @@ const stripFields = ({ } export const transform = ({ + $inc, adapter, data, fields, @@ -406,7 +408,7 @@ export const transform = ({ }: Args) => { if (Array.isArray(data)) { for (const item of data) { - transform({ adapter, data: item, fields, globalSlug, operation, validateRelationships }) + transform({ $inc, adapter, data: item, fields, globalSlug, operation, validateRelationships }) } return } @@ -438,13 +440,27 @@ export const transform = ({ data.globalType = globalSlug } - const sanitize: TraverseFieldsCallback = ({ field, ref: incomingRef }) => { + const sanitize: TraverseFieldsCallback = ({ field, parentPath, ref: incomingRef }) => { if (!incomingRef || typeof incomingRef !== 'object') { return } const ref = incomingRef as Record + if ( + $inc && + field.type === 'number' && + operation === 'write' && + field.name in ref && + ref[field.name] + ) { + const value = ref[field.name] + if (value && typeof value === 'object' && '$inc' in value && typeof value.$inc === 'number') { + $inc[`${parentPath}${field.name}`] = value.$inc + delete ref[field.name] + } + } + if (field.type === 'date' && operation === 'read' && field.name in ref && ref[field.name]) { if (config.localization && fieldShouldBeLocalized({ field, parentIsLocalized })) { const fieldRef = ref[field.name] as Record diff --git a/packages/drizzle/src/transform/write/index.ts b/packages/drizzle/src/transform/write/index.ts index e70b91ff8c..5d875162da 100644 --- a/packages/drizzle/src/transform/write/index.ts +++ b/packages/drizzle/src/transform/write/index.ts @@ -8,6 +8,7 @@ import { traverseFields } from './traverseFields.js' type Args = { adapter: DrizzleAdapter data: Record + enableAtomicWrites?: boolean fields: FlattenedField[] parentIsLocalized?: boolean path?: string @@ -17,6 +18,7 @@ type Args = { export const transformForWrite = ({ adapter, data, + enableAtomicWrites, fields, parentIsLocalized, path = '', @@ -48,6 +50,7 @@ export const transformForWrite = ({ blocksToDelete: rowToInsert.blocksToDelete, columnPrefix: '', data, + enableAtomicWrites, fieldPrefix: '', fields, locales: rowToInsert.locales, diff --git a/packages/drizzle/src/transform/write/traverseFields.ts b/packages/drizzle/src/transform/write/traverseFields.ts index e815733efa..feb3b17662 100644 --- a/packages/drizzle/src/transform/write/traverseFields.ts +++ b/packages/drizzle/src/transform/write/traverseFields.ts @@ -1,6 +1,5 @@ -import type { FlattenedField } from 'payload' - import { sql } from 'drizzle-orm' +import { APIError, type FlattenedField } from 'payload' import { fieldIsVirtual, fieldShouldBeLocalized } from 'payload/shared' import toSnakeCase from 'to-snake-case' @@ -41,6 +40,7 @@ type Args = { */ columnPrefix: string data: Record + enableAtomicWrites?: boolean existingLocales?: Record[] /** * A prefix that will retain camel-case formatting, representing prior fields @@ -87,6 +87,7 @@ export const traverseFields = ({ blocksToDelete, columnPrefix, data, + enableAtomicWrites, existingLocales, fieldPrefix, fields, @@ -268,6 +269,7 @@ export const traverseFields = ({ blocksToDelete, columnPrefix: `${columnName}_`, data: localeData as Record, + enableAtomicWrites, existingLocales, fieldPrefix: `${fieldName}_`, fields: field.flattenedFields, @@ -553,6 +555,22 @@ export const traverseFields = ({ formattedValue = JSON.stringify(value) } + if ( + field.type === 'number' && + value && + typeof value === 'object' && + '$inc' in value && + typeof value.$inc === 'number' + ) { + if (!enableAtomicWrites) { + throw new APIError( + 'The passed data must not contain any nested fields for atomic writes', + ) + } + + formattedValue = sql.raw(`${columnName} + ${value.$inc}`) + } + if (field.type === 'date') { if (typeof value === 'number' && !Number.isNaN(value)) { formattedValue = new Date(value).toISOString() diff --git a/packages/drizzle/src/updateOne.ts b/packages/drizzle/src/updateOne.ts index ef451c9436..3bd37e4682 100644 --- a/packages/drizzle/src/updateOne.ts +++ b/packages/drizzle/src/updateOne.ts @@ -151,6 +151,7 @@ export const updateOne: UpdateOne = async function updateOne( const { row } = transformForWrite({ adapter: this, data, + enableAtomicWrites: true, fields: collection.flattenedFields, tableName, }) diff --git a/packages/drizzle/src/upsertRow/index.ts b/packages/drizzle/src/upsertRow/index.ts index 34abf51075..ad10c5fd14 100644 --- a/packages/drizzle/src/upsertRow/index.ts +++ b/packages/drizzle/src/upsertRow/index.ts @@ -44,6 +44,7 @@ export const upsertRow = async | TypeWithID>( const rowToInsert = transformForWrite({ adapter, data, + enableAtomicWrites: false, fields, path, tableName, diff --git a/packages/payload/src/utilities/traverseFields.ts b/packages/payload/src/utilities/traverseFields.ts index 4f8408735f..f3981c6811 100644 --- a/packages/payload/src/utilities/traverseFields.ts +++ b/packages/payload/src/utilities/traverseFields.ts @@ -5,6 +5,7 @@ import { fieldAffectsData, fieldHasSubFields, fieldShouldBeLocalized, + tabHasName, } from '../fields/config/types.js' const traverseArrayOrBlocksField = ({ @@ -16,6 +17,7 @@ const traverseArrayOrBlocksField = ({ fillEmpty, leavesFirst, parentIsLocalized, + parentPath, parentRef, }: { callback: TraverseFieldsCallback @@ -26,6 +28,7 @@ const traverseArrayOrBlocksField = ({ fillEmpty: boolean leavesFirst: boolean parentIsLocalized: boolean + parentPath?: string parentRef?: unknown }) => { if (fillEmpty) { @@ -38,6 +41,7 @@ const traverseArrayOrBlocksField = ({ isTopLevel: false, leavesFirst, parentIsLocalized: parentIsLocalized || field.localized, + parentPath: `${parentPath}${field.name}.`, parentRef, }) } @@ -55,6 +59,7 @@ const traverseArrayOrBlocksField = ({ isTopLevel: false, leavesFirst, parentIsLocalized: parentIsLocalized || field.localized, + parentPath: `${parentPath}${field.name}.`, parentRef, }) } @@ -88,6 +93,7 @@ const traverseArrayOrBlocksField = ({ isTopLevel: false, leavesFirst, parentIsLocalized: parentIsLocalized || field.localized, + parentPath: `${parentPath}${field.name}.`, parentRef, ref, }) @@ -105,6 +111,7 @@ export type TraverseFieldsCallback = (args: { */ next?: () => void parentIsLocalized: boolean + parentPath: string /** * The parent reference object */ @@ -130,6 +137,7 @@ type TraverseFieldsArgs = { */ leavesFirst?: boolean parentIsLocalized?: boolean + parentPath?: string parentRef?: Record | unknown ref?: Record | unknown } @@ -152,6 +160,7 @@ export const traverseFields = ({ isTopLevel = true, leavesFirst = false, parentIsLocalized, + parentPath = '', parentRef = {}, ref = {}, }: TraverseFieldsArgs): void => { @@ -172,12 +181,19 @@ export const traverseFields = ({ if ( !leavesFirst && callback && - callback({ field, next, parentIsLocalized: parentIsLocalized!, parentRef, ref }) + callback({ field, next, parentIsLocalized: parentIsLocalized!, parentPath, parentRef, ref }) ) { return true } else if (leavesFirst) { callbackStack.push(() => - callback({ field, next, parentIsLocalized: parentIsLocalized!, parentRef, ref }), + callback({ + field, + next, + parentIsLocalized: parentIsLocalized!, + parentPath, + parentRef, + ref, + }), ) } @@ -220,6 +236,7 @@ export const traverseFields = ({ field: { ...tab, type: 'tab' }, next, parentIsLocalized: parentIsLocalized!, + parentPath, parentRef: currentParentRef, ref: tabRef, }) @@ -231,6 +248,7 @@ export const traverseFields = ({ field: { ...tab, type: 'tab' }, next, parentIsLocalized: parentIsLocalized!, + parentPath, parentRef: currentParentRef, ref: tabRef, }), @@ -254,6 +272,7 @@ export const traverseFields = ({ isTopLevel: false, leavesFirst, parentIsLocalized: true, + parentPath: `${parentPath}${tab.name}.`, parentRef: currentParentRef, ref: tabRef[key as keyof typeof tabRef], }) @@ -268,6 +287,7 @@ export const traverseFields = ({ field: { ...tab, type: 'tab' }, next, parentIsLocalized: parentIsLocalized!, + parentPath, parentRef: currentParentRef, ref: tabRef, }) @@ -279,6 +299,7 @@ export const traverseFields = ({ field: { ...tab, type: 'tab' }, next, parentIsLocalized: parentIsLocalized!, + parentPath, parentRef: currentParentRef, ref: tabRef, }), @@ -296,6 +317,7 @@ export const traverseFields = ({ isTopLevel: false, leavesFirst, parentIsLocalized: false, + parentPath: tabHasName(tab) ? `${parentPath}${tab.name}` : parentPath, parentRef: currentParentRef, ref: tabRef, }) @@ -352,6 +374,7 @@ export const traverseFields = ({ isTopLevel: false, leavesFirst, parentIsLocalized: true, + parentPath: field.name ? `${parentPath}${field.name}` : parentPath, parentRef: currentParentRef, ref: currentRef[key as keyof typeof currentRef], }) @@ -426,6 +449,7 @@ export const traverseFields = ({ isTopLevel: false, leavesFirst, parentIsLocalized, + parentPath, parentRef: currentParentRef, ref: currentRef, }) diff --git a/test/database/int.spec.ts b/test/database/int.spec.ts index 34bcc13da9..ecaf364acb 100644 --- a/test/database/int.spec.ts +++ b/test/database/int.spec.ts @@ -2836,6 +2836,34 @@ describe('database', () => { expect(res.arrayWithIDs[0].text).toBe('some text') }) + it('should allow incremental number update', async () => { + const post = await payload.create({ collection: 'posts', data: { number: 1, title: 'post' } }) + + const res = await payload.db.updateOne({ + data: { + number: { + $inc: 10, + }, + }, + collection: 'posts', + where: { id: { equals: post.id } }, + }) + + expect(res.number).toBe(11) + + const res2 = await payload.db.updateOne({ + data: { + number: { + $inc: -3, + }, + }, + collection: 'posts', + where: { id: { equals: post.id } }, + }) + + expect(res2.number).toBe(8) + }) + it('should support x3 nesting blocks', async () => { const res = await payload.create({ collection: 'posts',