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 } },
})
```
This commit is contained in:
Sasha
2025-07-16 07:53:45 +03:00
committed by GitHub
parent 2a59c5bf8c
commit 841bf891d0
8 changed files with 106 additions and 10 deletions

View File

@@ -1,4 +1,4 @@
import type { MongooseUpdateQueryOptions } from 'mongoose' import type { MongooseUpdateQueryOptions, UpdateQuery } from 'mongoose'
import type { UpdateOne } from 'payload' import type { UpdateOne } from 'payload'
import type { MongooseAdapter } from './index.js' import type { MongooseAdapter } from './index.js'
@@ -50,15 +50,20 @@ export const updateOne: UpdateOne = async function updateOne(
let result let result
transform({ adapter: this, data, fields, operation: 'write' }) const $inc: Record<string, number> = {}
let updateData: UpdateQuery<any> = data
transform({ $inc, adapter: this, data, fields, operation: 'write' })
if (Object.keys($inc).length) {
updateData = { $inc, $set: updateData }
}
try { try {
if (returning === false) { if (returning === false) {
await Model.updateOne(query, data, options) await Model.updateOne(query, updateData, options)
transform({ adapter: this, data, fields, operation: 'read' }) transform({ adapter: this, data, fields, operation: 'read' })
return null return null
} else { } else {
result = await Model.findOneAndUpdate(query, data, options) result = await Model.findOneAndUpdate(query, updateData, options)
} }
} catch (error) { } catch (error) {
handleError({ collection: collectionSlug, error, req }) handleError({ collection: collectionSlug, error, req })

View File

@@ -208,6 +208,7 @@ const sanitizeDate = ({
} }
type Args = { type Args = {
$inc?: Record<string, number>
/** instance of the adapter */ /** instance of the adapter */
adapter: MongooseAdapter adapter: MongooseAdapter
/** data to transform, can be an array of documents or a single document */ /** data to transform, can be an array of documents or a single document */
@@ -396,6 +397,7 @@ const stripFields = ({
} }
export const transform = ({ export const transform = ({
$inc,
adapter, adapter,
data, data,
fields, fields,
@@ -406,7 +408,7 @@ export const transform = ({
}: Args) => { }: Args) => {
if (Array.isArray(data)) { if (Array.isArray(data)) {
for (const item of data) { for (const item of data) {
transform({ adapter, data: item, fields, globalSlug, operation, validateRelationships }) transform({ $inc, adapter, data: item, fields, globalSlug, operation, validateRelationships })
} }
return return
} }
@@ -438,13 +440,27 @@ export const transform = ({
data.globalType = globalSlug data.globalType = globalSlug
} }
const sanitize: TraverseFieldsCallback = ({ field, ref: incomingRef }) => { const sanitize: TraverseFieldsCallback = ({ field, parentPath, ref: incomingRef }) => {
if (!incomingRef || typeof incomingRef !== 'object') { if (!incomingRef || typeof incomingRef !== 'object') {
return return
} }
const ref = incomingRef as Record<string, unknown> const ref = incomingRef as Record<string, unknown>
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 (field.type === 'date' && operation === 'read' && field.name in ref && ref[field.name]) {
if (config.localization && fieldShouldBeLocalized({ field, parentIsLocalized })) { if (config.localization && fieldShouldBeLocalized({ field, parentIsLocalized })) {
const fieldRef = ref[field.name] as Record<string, unknown> const fieldRef = ref[field.name] as Record<string, unknown>

View File

@@ -8,6 +8,7 @@ import { traverseFields } from './traverseFields.js'
type Args = { type Args = {
adapter: DrizzleAdapter adapter: DrizzleAdapter
data: Record<string, unknown> data: Record<string, unknown>
enableAtomicWrites?: boolean
fields: FlattenedField[] fields: FlattenedField[]
parentIsLocalized?: boolean parentIsLocalized?: boolean
path?: string path?: string
@@ -17,6 +18,7 @@ type Args = {
export const transformForWrite = ({ export const transformForWrite = ({
adapter, adapter,
data, data,
enableAtomicWrites,
fields, fields,
parentIsLocalized, parentIsLocalized,
path = '', path = '',
@@ -48,6 +50,7 @@ export const transformForWrite = ({
blocksToDelete: rowToInsert.blocksToDelete, blocksToDelete: rowToInsert.blocksToDelete,
columnPrefix: '', columnPrefix: '',
data, data,
enableAtomicWrites,
fieldPrefix: '', fieldPrefix: '',
fields, fields,
locales: rowToInsert.locales, locales: rowToInsert.locales,

View File

@@ -1,6 +1,5 @@
import type { FlattenedField } from 'payload'
import { sql } from 'drizzle-orm' import { sql } from 'drizzle-orm'
import { APIError, type FlattenedField } from 'payload'
import { fieldIsVirtual, fieldShouldBeLocalized } from 'payload/shared' import { fieldIsVirtual, fieldShouldBeLocalized } from 'payload/shared'
import toSnakeCase from 'to-snake-case' import toSnakeCase from 'to-snake-case'
@@ -41,6 +40,7 @@ type Args = {
*/ */
columnPrefix: string columnPrefix: string
data: Record<string, unknown> data: Record<string, unknown>
enableAtomicWrites?: boolean
existingLocales?: Record<string, unknown>[] existingLocales?: Record<string, unknown>[]
/** /**
* A prefix that will retain camel-case formatting, representing prior fields * A prefix that will retain camel-case formatting, representing prior fields
@@ -87,6 +87,7 @@ export const traverseFields = ({
blocksToDelete, blocksToDelete,
columnPrefix, columnPrefix,
data, data,
enableAtomicWrites,
existingLocales, existingLocales,
fieldPrefix, fieldPrefix,
fields, fields,
@@ -268,6 +269,7 @@ export const traverseFields = ({
blocksToDelete, blocksToDelete,
columnPrefix: `${columnName}_`, columnPrefix: `${columnName}_`,
data: localeData as Record<string, unknown>, data: localeData as Record<string, unknown>,
enableAtomicWrites,
existingLocales, existingLocales,
fieldPrefix: `${fieldName}_`, fieldPrefix: `${fieldName}_`,
fields: field.flattenedFields, fields: field.flattenedFields,
@@ -553,6 +555,22 @@ export const traverseFields = ({
formattedValue = JSON.stringify(value) 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 (field.type === 'date') {
if (typeof value === 'number' && !Number.isNaN(value)) { if (typeof value === 'number' && !Number.isNaN(value)) {
formattedValue = new Date(value).toISOString() formattedValue = new Date(value).toISOString()

View File

@@ -151,6 +151,7 @@ export const updateOne: UpdateOne = async function updateOne(
const { row } = transformForWrite({ const { row } = transformForWrite({
adapter: this, adapter: this,
data, data,
enableAtomicWrites: true,
fields: collection.flattenedFields, fields: collection.flattenedFields,
tableName, tableName,
}) })

View File

@@ -44,6 +44,7 @@ export const upsertRow = async <T extends Record<string, unknown> | TypeWithID>(
const rowToInsert = transformForWrite({ const rowToInsert = transformForWrite({
adapter, adapter,
data, data,
enableAtomicWrites: false,
fields, fields,
path, path,
tableName, tableName,

View File

@@ -5,6 +5,7 @@ import {
fieldAffectsData, fieldAffectsData,
fieldHasSubFields, fieldHasSubFields,
fieldShouldBeLocalized, fieldShouldBeLocalized,
tabHasName,
} from '../fields/config/types.js' } from '../fields/config/types.js'
const traverseArrayOrBlocksField = ({ const traverseArrayOrBlocksField = ({
@@ -16,6 +17,7 @@ const traverseArrayOrBlocksField = ({
fillEmpty, fillEmpty,
leavesFirst, leavesFirst,
parentIsLocalized, parentIsLocalized,
parentPath,
parentRef, parentRef,
}: { }: {
callback: TraverseFieldsCallback callback: TraverseFieldsCallback
@@ -26,6 +28,7 @@ const traverseArrayOrBlocksField = ({
fillEmpty: boolean fillEmpty: boolean
leavesFirst: boolean leavesFirst: boolean
parentIsLocalized: boolean parentIsLocalized: boolean
parentPath?: string
parentRef?: unknown parentRef?: unknown
}) => { }) => {
if (fillEmpty) { if (fillEmpty) {
@@ -38,6 +41,7 @@ const traverseArrayOrBlocksField = ({
isTopLevel: false, isTopLevel: false,
leavesFirst, leavesFirst,
parentIsLocalized: parentIsLocalized || field.localized, parentIsLocalized: parentIsLocalized || field.localized,
parentPath: `${parentPath}${field.name}.`,
parentRef, parentRef,
}) })
} }
@@ -55,6 +59,7 @@ const traverseArrayOrBlocksField = ({
isTopLevel: false, isTopLevel: false,
leavesFirst, leavesFirst,
parentIsLocalized: parentIsLocalized || field.localized, parentIsLocalized: parentIsLocalized || field.localized,
parentPath: `${parentPath}${field.name}.`,
parentRef, parentRef,
}) })
} }
@@ -88,6 +93,7 @@ const traverseArrayOrBlocksField = ({
isTopLevel: false, isTopLevel: false,
leavesFirst, leavesFirst,
parentIsLocalized: parentIsLocalized || field.localized, parentIsLocalized: parentIsLocalized || field.localized,
parentPath: `${parentPath}${field.name}.`,
parentRef, parentRef,
ref, ref,
}) })
@@ -105,6 +111,7 @@ export type TraverseFieldsCallback = (args: {
*/ */
next?: () => void next?: () => void
parentIsLocalized: boolean parentIsLocalized: boolean
parentPath: string
/** /**
* The parent reference object * The parent reference object
*/ */
@@ -130,6 +137,7 @@ type TraverseFieldsArgs = {
*/ */
leavesFirst?: boolean leavesFirst?: boolean
parentIsLocalized?: boolean parentIsLocalized?: boolean
parentPath?: string
parentRef?: Record<string, unknown> | unknown parentRef?: Record<string, unknown> | unknown
ref?: Record<string, unknown> | unknown ref?: Record<string, unknown> | unknown
} }
@@ -152,6 +160,7 @@ export const traverseFields = ({
isTopLevel = true, isTopLevel = true,
leavesFirst = false, leavesFirst = false,
parentIsLocalized, parentIsLocalized,
parentPath = '',
parentRef = {}, parentRef = {},
ref = {}, ref = {},
}: TraverseFieldsArgs): void => { }: TraverseFieldsArgs): void => {
@@ -172,12 +181,19 @@ export const traverseFields = ({
if ( if (
!leavesFirst && !leavesFirst &&
callback && callback &&
callback({ field, next, parentIsLocalized: parentIsLocalized!, parentRef, ref }) callback({ field, next, parentIsLocalized: parentIsLocalized!, parentPath, parentRef, ref })
) { ) {
return true return true
} else if (leavesFirst) { } else if (leavesFirst) {
callbackStack.push(() => 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' }, field: { ...tab, type: 'tab' },
next, next,
parentIsLocalized: parentIsLocalized!, parentIsLocalized: parentIsLocalized!,
parentPath,
parentRef: currentParentRef, parentRef: currentParentRef,
ref: tabRef, ref: tabRef,
}) })
@@ -231,6 +248,7 @@ export const traverseFields = ({
field: { ...tab, type: 'tab' }, field: { ...tab, type: 'tab' },
next, next,
parentIsLocalized: parentIsLocalized!, parentIsLocalized: parentIsLocalized!,
parentPath,
parentRef: currentParentRef, parentRef: currentParentRef,
ref: tabRef, ref: tabRef,
}), }),
@@ -254,6 +272,7 @@ export const traverseFields = ({
isTopLevel: false, isTopLevel: false,
leavesFirst, leavesFirst,
parentIsLocalized: true, parentIsLocalized: true,
parentPath: `${parentPath}${tab.name}.`,
parentRef: currentParentRef, parentRef: currentParentRef,
ref: tabRef[key as keyof typeof tabRef], ref: tabRef[key as keyof typeof tabRef],
}) })
@@ -268,6 +287,7 @@ export const traverseFields = ({
field: { ...tab, type: 'tab' }, field: { ...tab, type: 'tab' },
next, next,
parentIsLocalized: parentIsLocalized!, parentIsLocalized: parentIsLocalized!,
parentPath,
parentRef: currentParentRef, parentRef: currentParentRef,
ref: tabRef, ref: tabRef,
}) })
@@ -279,6 +299,7 @@ export const traverseFields = ({
field: { ...tab, type: 'tab' }, field: { ...tab, type: 'tab' },
next, next,
parentIsLocalized: parentIsLocalized!, parentIsLocalized: parentIsLocalized!,
parentPath,
parentRef: currentParentRef, parentRef: currentParentRef,
ref: tabRef, ref: tabRef,
}), }),
@@ -296,6 +317,7 @@ export const traverseFields = ({
isTopLevel: false, isTopLevel: false,
leavesFirst, leavesFirst,
parentIsLocalized: false, parentIsLocalized: false,
parentPath: tabHasName(tab) ? `${parentPath}${tab.name}` : parentPath,
parentRef: currentParentRef, parentRef: currentParentRef,
ref: tabRef, ref: tabRef,
}) })
@@ -352,6 +374,7 @@ export const traverseFields = ({
isTopLevel: false, isTopLevel: false,
leavesFirst, leavesFirst,
parentIsLocalized: true, parentIsLocalized: true,
parentPath: field.name ? `${parentPath}${field.name}` : parentPath,
parentRef: currentParentRef, parentRef: currentParentRef,
ref: currentRef[key as keyof typeof currentRef], ref: currentRef[key as keyof typeof currentRef],
}) })
@@ -426,6 +449,7 @@ export const traverseFields = ({
isTopLevel: false, isTopLevel: false,
leavesFirst, leavesFirst,
parentIsLocalized, parentIsLocalized,
parentPath,
parentRef: currentParentRef, parentRef: currentParentRef,
ref: currentRef, ref: currentRef,
}) })

View File

@@ -2836,6 +2836,34 @@ describe('database', () => {
expect(res.arrayWithIDs[0].text).toBe('some text') 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 () => { it('should support x3 nesting blocks', async () => {
const res = await payload.create({ const res = await payload.create({
collection: 'posts', collection: 'posts',