Skip to content

Commit 841bf89

Browse files
authored
feat: atomic number field updates (#13118)
Based on #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 } }, }) ```
1 parent 2a59c5b commit 841bf89

File tree

8 files changed

+106
-10
lines changed

8 files changed

+106
-10
lines changed

packages/db-mongodb/src/updateOne.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { MongooseUpdateQueryOptions } from 'mongoose'
1+
import type { MongooseUpdateQueryOptions, UpdateQuery } from 'mongoose'
22
import type { UpdateOne } from 'payload'
33

44
import type { MongooseAdapter } from './index.js'
@@ -50,15 +50,20 @@ export const updateOne: UpdateOne = async function updateOne(
5050

5151
let result
5252

53-
transform({ adapter: this, data, fields, operation: 'write' })
53+
const $inc: Record<string, number> = {}
54+
let updateData: UpdateQuery<any> = data
55+
transform({ $inc, adapter: this, data, fields, operation: 'write' })
56+
if (Object.keys($inc).length) {
57+
updateData = { $inc, $set: updateData }
58+
}
5459

5560
try {
5661
if (returning === false) {
57-
await Model.updateOne(query, data, options)
62+
await Model.updateOne(query, updateData, options)
5863
transform({ adapter: this, data, fields, operation: 'read' })
5964
return null
6065
} else {
61-
result = await Model.findOneAndUpdate(query, data, options)
66+
result = await Model.findOneAndUpdate(query, updateData, options)
6267
}
6368
} catch (error) {
6469
handleError({ collection: collectionSlug, error, req })

packages/db-mongodb/src/utilities/transform.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,7 @@ const sanitizeDate = ({
208208
}
209209

210210
type Args = {
211+
$inc?: Record<string, number>
211212
/** instance of the adapter */
212213
adapter: MongooseAdapter
213214
/** data to transform, can be an array of documents or a single document */
@@ -396,6 +397,7 @@ const stripFields = ({
396397
}
397398

398399
export const transform = ({
400+
$inc,
399401
adapter,
400402
data,
401403
fields,
@@ -406,7 +408,7 @@ export const transform = ({
406408
}: Args) => {
407409
if (Array.isArray(data)) {
408410
for (const item of data) {
409-
transform({ adapter, data: item, fields, globalSlug, operation, validateRelationships })
411+
transform({ $inc, adapter, data: item, fields, globalSlug, operation, validateRelationships })
410412
}
411413
return
412414
}
@@ -438,13 +440,27 @@ export const transform = ({
438440
data.globalType = globalSlug
439441
}
440442

441-
const sanitize: TraverseFieldsCallback = ({ field, ref: incomingRef }) => {
443+
const sanitize: TraverseFieldsCallback = ({ field, parentPath, ref: incomingRef }) => {
442444
if (!incomingRef || typeof incomingRef !== 'object') {
443445
return
444446
}
445447

446448
const ref = incomingRef as Record<string, unknown>
447449

450+
if (
451+
$inc &&
452+
field.type === 'number' &&
453+
operation === 'write' &&
454+
field.name in ref &&
455+
ref[field.name]
456+
) {
457+
const value = ref[field.name]
458+
if (value && typeof value === 'object' && '$inc' in value && typeof value.$inc === 'number') {
459+
$inc[`${parentPath}${field.name}`] = value.$inc
460+
delete ref[field.name]
461+
}
462+
}
463+
448464
if (field.type === 'date' && operation === 'read' && field.name in ref && ref[field.name]) {
449465
if (config.localization && fieldShouldBeLocalized({ field, parentIsLocalized })) {
450466
const fieldRef = ref[field.name] as Record<string, unknown>

packages/drizzle/src/transform/write/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { traverseFields } from './traverseFields.js'
88
type Args = {
99
adapter: DrizzleAdapter
1010
data: Record<string, unknown>
11+
enableAtomicWrites?: boolean
1112
fields: FlattenedField[]
1213
parentIsLocalized?: boolean
1314
path?: string
@@ -17,6 +18,7 @@ type Args = {
1718
export const transformForWrite = ({
1819
adapter,
1920
data,
21+
enableAtomicWrites,
2022
fields,
2123
parentIsLocalized,
2224
path = '',
@@ -48,6 +50,7 @@ export const transformForWrite = ({
4850
blocksToDelete: rowToInsert.blocksToDelete,
4951
columnPrefix: '',
5052
data,
53+
enableAtomicWrites,
5154
fieldPrefix: '',
5255
fields,
5356
locales: rowToInsert.locales,

packages/drizzle/src/transform/write/traverseFields.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
1-
import type { FlattenedField } from 'payload'
2-
31
import { sql } from 'drizzle-orm'
2+
import { APIError, type FlattenedField } from 'payload'
43
import { fieldIsVirtual, fieldShouldBeLocalized } from 'payload/shared'
54
import toSnakeCase from 'to-snake-case'
65

@@ -41,6 +40,7 @@ type Args = {
4140
*/
4241
columnPrefix: string
4342
data: Record<string, unknown>
43+
enableAtomicWrites?: boolean
4444
existingLocales?: Record<string, unknown>[]
4545
/**
4646
* A prefix that will retain camel-case formatting, representing prior fields
@@ -87,6 +87,7 @@ export const traverseFields = ({
8787
blocksToDelete,
8888
columnPrefix,
8989
data,
90+
enableAtomicWrites,
9091
existingLocales,
9192
fieldPrefix,
9293
fields,
@@ -268,6 +269,7 @@ export const traverseFields = ({
268269
blocksToDelete,
269270
columnPrefix: `${columnName}_`,
270271
data: localeData as Record<string, unknown>,
272+
enableAtomicWrites,
271273
existingLocales,
272274
fieldPrefix: `${fieldName}_`,
273275
fields: field.flattenedFields,
@@ -553,6 +555,22 @@ export const traverseFields = ({
553555
formattedValue = JSON.stringify(value)
554556
}
555557

558+
if (
559+
field.type === 'number' &&
560+
value &&
561+
typeof value === 'object' &&
562+
'$inc' in value &&
563+
typeof value.$inc === 'number'
564+
) {
565+
if (!enableAtomicWrites) {
566+
throw new APIError(
567+
'The passed data must not contain any nested fields for atomic writes',
568+
)
569+
}
570+
571+
formattedValue = sql.raw(`${columnName} + ${value.$inc}`)
572+
}
573+
556574
if (field.type === 'date') {
557575
if (typeof value === 'number' && !Number.isNaN(value)) {
558576
formattedValue = new Date(value).toISOString()

packages/drizzle/src/updateOne.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@ export const updateOne: UpdateOne = async function updateOne(
151151
const { row } = transformForWrite({
152152
adapter: this,
153153
data,
154+
enableAtomicWrites: true,
154155
fields: collection.flattenedFields,
155156
tableName,
156157
})

packages/drizzle/src/upsertRow/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ export const upsertRow = async <T extends Record<string, unknown> | TypeWithID>(
4444
const rowToInsert = transformForWrite({
4545
adapter,
4646
data,
47+
enableAtomicWrites: false,
4748
fields,
4849
path,
4950
tableName,

packages/payload/src/utilities/traverseFields.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
fieldAffectsData,
66
fieldHasSubFields,
77
fieldShouldBeLocalized,
8+
tabHasName,
89
} from '../fields/config/types.js'
910

1011
const traverseArrayOrBlocksField = ({
@@ -16,6 +17,7 @@ const traverseArrayOrBlocksField = ({
1617
fillEmpty,
1718
leavesFirst,
1819
parentIsLocalized,
20+
parentPath,
1921
parentRef,
2022
}: {
2123
callback: TraverseFieldsCallback
@@ -26,6 +28,7 @@ const traverseArrayOrBlocksField = ({
2628
fillEmpty: boolean
2729
leavesFirst: boolean
2830
parentIsLocalized: boolean
31+
parentPath?: string
2932
parentRef?: unknown
3033
}) => {
3134
if (fillEmpty) {
@@ -38,6 +41,7 @@ const traverseArrayOrBlocksField = ({
3841
isTopLevel: false,
3942
leavesFirst,
4043
parentIsLocalized: parentIsLocalized || field.localized,
44+
parentPath: `${parentPath}${field.name}.`,
4145
parentRef,
4246
})
4347
}
@@ -55,6 +59,7 @@ const traverseArrayOrBlocksField = ({
5559
isTopLevel: false,
5660
leavesFirst,
5761
parentIsLocalized: parentIsLocalized || field.localized,
62+
parentPath: `${parentPath}${field.name}.`,
5863
parentRef,
5964
})
6065
}
@@ -88,6 +93,7 @@ const traverseArrayOrBlocksField = ({
8893
isTopLevel: false,
8994
leavesFirst,
9095
parentIsLocalized: parentIsLocalized || field.localized,
96+
parentPath: `${parentPath}${field.name}.`,
9197
parentRef,
9298
ref,
9399
})
@@ -105,6 +111,7 @@ export type TraverseFieldsCallback = (args: {
105111
*/
106112
next?: () => void
107113
parentIsLocalized: boolean
114+
parentPath: string
108115
/**
109116
* The parent reference object
110117
*/
@@ -130,6 +137,7 @@ type TraverseFieldsArgs = {
130137
*/
131138
leavesFirst?: boolean
132139
parentIsLocalized?: boolean
140+
parentPath?: string
133141
parentRef?: Record<string, unknown> | unknown
134142
ref?: Record<string, unknown> | unknown
135143
}
@@ -152,6 +160,7 @@ export const traverseFields = ({
152160
isTopLevel = true,
153161
leavesFirst = false,
154162
parentIsLocalized,
163+
parentPath = '',
155164
parentRef = {},
156165
ref = {},
157166
}: TraverseFieldsArgs): void => {
@@ -172,12 +181,19 @@ export const traverseFields = ({
172181
if (
173182
!leavesFirst &&
174183
callback &&
175-
callback({ field, next, parentIsLocalized: parentIsLocalized!, parentRef, ref })
184+
callback({ field, next, parentIsLocalized: parentIsLocalized!, parentPath, parentRef, ref })
176185
) {
177186
return true
178187
} else if (leavesFirst) {
179188
callbackStack.push(() =>
180-
callback({ field, next, parentIsLocalized: parentIsLocalized!, parentRef, ref }),
189+
callback({
190+
field,
191+
next,
192+
parentIsLocalized: parentIsLocalized!,
193+
parentPath,
194+
parentRef,
195+
ref,
196+
}),
181197
)
182198
}
183199

@@ -220,6 +236,7 @@ export const traverseFields = ({
220236
field: { ...tab, type: 'tab' },
221237
next,
222238
parentIsLocalized: parentIsLocalized!,
239+
parentPath,
223240
parentRef: currentParentRef,
224241
ref: tabRef,
225242
})
@@ -231,6 +248,7 @@ export const traverseFields = ({
231248
field: { ...tab, type: 'tab' },
232249
next,
233250
parentIsLocalized: parentIsLocalized!,
251+
parentPath,
234252
parentRef: currentParentRef,
235253
ref: tabRef,
236254
}),
@@ -254,6 +272,7 @@ export const traverseFields = ({
254272
isTopLevel: false,
255273
leavesFirst,
256274
parentIsLocalized: true,
275+
parentPath: `${parentPath}${tab.name}.`,
257276
parentRef: currentParentRef,
258277
ref: tabRef[key as keyof typeof tabRef],
259278
})
@@ -268,6 +287,7 @@ export const traverseFields = ({
268287
field: { ...tab, type: 'tab' },
269288
next,
270289
parentIsLocalized: parentIsLocalized!,
290+
parentPath,
271291
parentRef: currentParentRef,
272292
ref: tabRef,
273293
})
@@ -279,6 +299,7 @@ export const traverseFields = ({
279299
field: { ...tab, type: 'tab' },
280300
next,
281301
parentIsLocalized: parentIsLocalized!,
302+
parentPath,
282303
parentRef: currentParentRef,
283304
ref: tabRef,
284305
}),
@@ -296,6 +317,7 @@ export const traverseFields = ({
296317
isTopLevel: false,
297318
leavesFirst,
298319
parentIsLocalized: false,
320+
parentPath: tabHasName(tab) ? `${parentPath}${tab.name}` : parentPath,
299321
parentRef: currentParentRef,
300322
ref: tabRef,
301323
})
@@ -352,6 +374,7 @@ export const traverseFields = ({
352374
isTopLevel: false,
353375
leavesFirst,
354376
parentIsLocalized: true,
377+
parentPath: field.name ? `${parentPath}${field.name}` : parentPath,
355378
parentRef: currentParentRef,
356379
ref: currentRef[key as keyof typeof currentRef],
357380
})
@@ -426,6 +449,7 @@ export const traverseFields = ({
426449
isTopLevel: false,
427450
leavesFirst,
428451
parentIsLocalized,
452+
parentPath,
429453
parentRef: currentParentRef,
430454
ref: currentRef,
431455
})

test/database/int.spec.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2836,6 +2836,34 @@ describe('database', () => {
28362836
expect(res.arrayWithIDs[0].text).toBe('some text')
28372837
})
28382838

2839+
it('should allow incremental number update', async () => {
2840+
const post = await payload.create({ collection: 'posts', data: { number: 1, title: 'post' } })
2841+
2842+
const res = await payload.db.updateOne({
2843+
data: {
2844+
number: {
2845+
$inc: 10,
2846+
},
2847+
},
2848+
collection: 'posts',
2849+
where: { id: { equals: post.id } },
2850+
})
2851+
2852+
expect(res.number).toBe(11)
2853+
2854+
const res2 = await payload.db.updateOne({
2855+
data: {
2856+
number: {
2857+
$inc: -3,
2858+
},
2859+
},
2860+
collection: 'posts',
2861+
where: { id: { equals: post.id } },
2862+
})
2863+
2864+
expect(res2.number).toBe(8)
2865+
})
2866+
28392867
it('should support x3 nesting blocks', async () => {
28402868
const res = await payload.create({
28412869
collection: 'posts',

0 commit comments

Comments
 (0)