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 { 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 })
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user