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:
@@ -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<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 {
|
||||
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 })
|
||||
|
||||
@@ -208,6 +208,7 @@ const sanitizeDate = ({
|
||||
}
|
||||
|
||||
type Args = {
|
||||
$inc?: Record<string, number>
|
||||
/** 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<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 (config.localization && fieldShouldBeLocalized({ field, parentIsLocalized })) {
|
||||
const fieldRef = ref[field.name] as Record<string, unknown>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { traverseFields } from './traverseFields.js'
|
||||
type Args = {
|
||||
adapter: DrizzleAdapter
|
||||
data: Record<string, unknown>
|
||||
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,
|
||||
|
||||
@@ -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<string, unknown>
|
||||
enableAtomicWrites?: boolean
|
||||
existingLocales?: Record<string, unknown>[]
|
||||
/**
|
||||
* 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<string, unknown>,
|
||||
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()
|
||||
|
||||
@@ -151,6 +151,7 @@ export const updateOne: UpdateOne = async function updateOne(
|
||||
const { row } = transformForWrite({
|
||||
adapter: this,
|
||||
data,
|
||||
enableAtomicWrites: true,
|
||||
fields: collection.flattenedFields,
|
||||
tableName,
|
||||
})
|
||||
|
||||
@@ -44,6 +44,7 @@ export const upsertRow = async <T extends Record<string, unknown> | TypeWithID>(
|
||||
const rowToInsert = transformForWrite({
|
||||
adapter,
|
||||
data,
|
||||
enableAtomicWrites: false,
|
||||
fields,
|
||||
path,
|
||||
tableName,
|
||||
|
||||
@@ -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<string, unknown> | unknown
|
||||
ref?: Record<string, unknown> | 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,
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user