This commit is contained in:
Alessio Gravili
2026-09-06 18:36:15 -07:00
parent 3e042e4304
commit d613e6f174
5 changed files with 244 additions and 13 deletions
+28 -4
View File
@@ -1,8 +1,9 @@
import type { QueryOptions } from 'mongoose'
import type { QueryFilter, QueryOptions } from 'mongoose'
import type { UpdateGlobal } from 'payload'
import type { MongooseAdapter } from './index.js'
import { buildQuery } from './queries/buildQuery.js'
import { buildProjectionFromSelect } from './utilities/buildProjectionFromSelect.js'
import { getGlobal } from './utilities/getEntity.js'
import { getSession } from './utilities/getSession.js'
@@ -10,16 +11,39 @@ import { transform } from './utilities/transform.js'
export const updateGlobal: UpdateGlobal = async function updateGlobal(
this: MongooseAdapter,
{ slug: globalSlug, data, options: optionsArgs = {}, req, returning, select },
{ slug: globalSlug, data, options: optionsArgs = {}, req, returning, select, where },
) {
const { globalConfig, Model } = getGlobal({ adapter: this, globalSlug })
const fields = globalConfig.fields
const query: QueryFilter<Record<string, unknown>> = {
globalType: globalSlug,
}
if (where) {
query.$and = [
await buildQuery({
adapter: this,
fields: globalConfig.flattenedFields,
globalSlug,
locale: req?.locale ?? undefined,
where,
}),
]
}
transform({ adapter: this, data, fields, globalSlug, operation: 'write' })
const baseOptions = {
...optionsArgs,
/**
* Without where: if a global does not exist yet, we should create it here.
* The user should always expect it exists and can be written to.
*
* With where: do not create it if condition doesn't match. No expectation that
* a global fulfilling this condition should exist.
*/
...(where ? { upsert: false } : {}),
session: await getSession(this, req),
// Timestamps are manually added by the write transform
timestamps: false,
@@ -37,11 +61,11 @@ export const updateGlobal: UpdateGlobal = async function updateGlobal(
}
if (returning === false) {
await Model.updateOne({ globalType: globalSlug }, data, baseOptions)
await Model.updateOne(query, data, baseOptions)
return null
}
const result: any = await Model.findOneAndUpdate({ globalType: globalSlug }, data, findOptions)
const result: any = await Model.findOneAndUpdate(query, data, findOptions)
transform({ adapter: this, data: result, fields, globalSlug, operation: 'read' })
+49 -4
View File
@@ -1,25 +1,70 @@
import type { SQL } from 'drizzle-orm'
import type { UpdateGlobalArgs } from 'payload'
import toSnakeCase from 'to-snake-case'
import type { DrizzleAdapter } from './types.js'
import { buildQuery } from './queries/buildQuery.js'
import { selectDistinct } from './queries/selectDistinct.js'
import { upsertRow } from './upsertRow/index.js'
import { getPrimaryDb } from './utilities/getPrimaryDb.js'
import { getTransaction } from './utilities/getTransaction.js'
export async function updateGlobal<T extends Record<string, unknown>>(
this: DrizzleAdapter,
{ slug, data, req, returning, select }: UpdateGlobalArgs,
): Promise<T> {
{ slug, data, req, returning, select, where: whereArg }: UpdateGlobalArgs,
): Promise<null | T> {
const globalConfig = this.payload.globals.config.find((config) => config.slug === slug)
const tableName = this.tableNameMap.get(toSnakeCase(globalConfig.slug))
const db = getPrimaryDb(this, await getTransaction(this, req))
const existingGlobal = await db.query[tableName].findFirst({})
// A conditional update must not create a global when nothing matches.
if (!existingGlobal && whereArg) {
return null
}
let idToUpdate = existingGlobal?.id
let whereToUse: SQL | undefined
if (whereArg) {
const { joins, selectFields, where } = buildQuery({
adapter: this,
fields: globalConfig.flattenedFields,
locale: req?.locale ?? undefined,
tableName,
where: whereArg,
})
if (joins.length) {
// Like updateOne, queries needing joins use a separate lookup. This check is not atomic.
const [matchingGlobal] = await selectDistinct({
adapter: this,
db,
joins,
query: ({ query }) => query.limit(1),
selectFields,
tableName,
where,
})
if (!matchingGlobal) {
return null
}
idToUpdate = matchingGlobal.id
} else {
// Conditions on this table are efficiently checked during the write
whereToUse = where
}
}
const result = await upsertRow<{ globalType: string } & T>({
...(existingGlobal ? { id: existingGlobal.id, operation: 'update' } : { operation: 'create' }),
...(existingGlobal
? { id: idToUpdate, operation: 'update', where: whereToUse }
: { operation: 'create' }),
adapter: this,
data,
db,
@@ -31,7 +76,7 @@ export async function updateGlobal<T extends Record<string, unknown>>(
tableName,
})
if (returning === false) {
if (!result || returning === false) {
return null
}
+5 -4
View File
@@ -407,13 +407,14 @@ export type UpdateGlobalArgs<T extends Record<string, unknown> = any> = {
returning?: boolean
select?: SelectType
slug: string
/**
* Returns null without updating if no global matches.
*/
where?: Where
}
/**
* @todo type as Promise<T | null> in 4.0
*/
export type UpdateGlobal = <T extends Record<string, unknown> = any>(
args: UpdateGlobalArgs<T>,
) => Promise<T>
) => Promise<null | T>
// export type UpdateOne = (args: UpdateOneArgs) => Promise<Document>
export type FindGlobalVersions = <T = JsonObject>(
@@ -17,6 +17,7 @@ import type {
} from '../config/types.js'
import { executeAccess } from '../../auth/executeAccess.js'
import { NotFound } from '../../errors/index.js'
import { afterChange } from '../../fields/hooks/afterChange/index.js'
import { afterRead } from '../../fields/hooks/afterRead/index.js'
import { beforeChange } from '../../fields/hooks/beforeChange/index.js'
@@ -353,12 +354,18 @@ export const updateOperation = async <
dataToUpdate.updatedAt = now
if (globalExists) {
resultWithLocales = await payload.db.updateGlobal({
const updatedGlobal = await payload.db.updateGlobal({
slug,
data: dataToUpdate,
req,
select,
})
if (!updatedGlobal) {
throw new NotFound(req.t)
}
resultWithLocales = updatedGlobal
} else {
resultWithLocales = await payload.db.createGlobal({
slug,
+154
View File
@@ -4451,6 +4451,160 @@ test.suite({ config: './config.ts' })('database', () => {
expect(createdAt).toBeLessThan(new Date(result.updatedAt as string).getTime())
})
test.describe('db.updateGlobal with where', () => {
/** A matching condition should save the change and return the updated global. */
test('should update the global when the condition matches', async ({ payload }) => {
const original = await payload.db.createGlobal({
slug: 'global-2',
data: { text: 'Original' },
})
const result = await payload.db.updateGlobal({
slug: 'global-2',
data: { text: 'Updated' },
where: { text: { equals: 'Original' } },
})
const stored = await payload.findGlobal({ slug: 'global-2' })
expect(result).toMatchObject({ id: original.id, text: 'Updated' })
expect(stored.text).toBe('Updated')
})
/** A failed condition should return null and leave the saved value alone. */
test('should not update the global when the condition does not match', async ({ payload }) => {
await payload.db.createGlobal({ slug: 'global-2', data: { text: 'Original' } })
const result = await payload.db.updateGlobal({
slug: 'global-2',
data: { text: 'Should not be saved' },
where: { text: { equals: 'Does not match' } },
})
const stored = await payload.findGlobal({ slug: 'global-2' })
expect(result).toBeNull()
expect(stored.text).toBe('Original')
})
/** Asking for no returned document must not skip the condition that protects the write. */
test('should still check the condition when returning is false', async ({ payload }) => {
await payload.db.createGlobal({ slug: 'global-2', data: { text: 'Original' } })
const result = await payload.db.updateGlobal({
slug: 'global-2',
data: { text: 'Should not be saved' },
returning: false,
where: { text: { equals: 'Does not match' } },
})
const stored = await payload.findGlobal({ slug: 'global-2' })
expect(result).toBeNull()
expect(stored.text).toBe('Original')
})
/** A failed conditional update must not create a new record, even if upsert is requested. */
test('should not create a global when no record matches', async ({ payload }) => {
const result = await payload.db.updateGlobal({
slug: 'global-2',
data: { text: 'Should not be created' },
options: { upsert: true },
where: { text: { equals: 'Missing' } },
})
const stored = await payload.db.findGlobal({ slug: 'global-2' })
expect(result).toBeNull()
expect(stored?.id).toBeUndefined()
})
/** Two writers use the same old timestamp. Only one should save; the other must return null. */
test('should allow only one concurrent update using the same timestamp', async ({
payload,
}) => {
const original = await payload.db.createGlobal({
slug: 'global-2',
data: { text: 'Original' },
})
const updatedAt = new Date(Date.parse(original.updatedAt) + 1).toISOString()
const where = { updatedAt: { equals: original.updatedAt } }
const results = await Promise.all([
payload.db.updateGlobal({
slug: 'global-2',
data: { text: 'First writer', updatedAt },
where,
}),
payload.db.updateGlobal({
slug: 'global-2',
data: { text: 'Second writer', updatedAt },
where,
}),
])
const saved = results.filter((result) => result !== null)
const stored = await payload.findGlobal({ slug: 'global-2' })
expect(saved).toHaveLength(1)
expect(results).toContain(null)
expect(['First writer', 'Second writer']).toContain(saved[0]?.text)
expect(stored).toMatchObject({ id: original.id, text: saved[0]?.text, updatedAt })
})
/**
* Setting updatedAt to null disables the automatic timestamp change, leaving nothing to save.
* The update must still return null when its condition fails.
*/
test('should check the condition for an empty update', async ({ payload }) => {
await payload.db.createGlobal({ slug: 'global-2', data: { text: 'Original' } })
const result = await payload.db.updateGlobal({
slug: 'global-2',
data: { updatedAt: null },
where: { text: { equals: 'Does not match' } },
})
const stored = await payload.findGlobal({ slug: 'global-2' })
expect(result).toBeNull()
expect(stored.text).toBe('Original')
})
/** SQL stores array entries separately. Rejecting an update must leave those entries alone too. */
test('should preserve array entries when the condition does not match', async ({ payload }) => {
const original = await payload.updateGlobal({
slug: 'header',
data: { itemsLvl1: [{ label: 'Original' }] },
})
const result = await payload.db.updateGlobal({
slug: 'header',
data: {
itemsLvl1: [{ id: original.itemsLvl1?.[0]?.id, label: 'Should not be saved' }],
updatedAt: null,
},
where: { 'itemsLvl1.label': { equals: 'Does not match' } },
})
const stored = await payload.findGlobal({ slug: 'header' })
expect(result).toBeNull()
expect(stored.itemsLvl1).toEqual(original.itemsLvl1)
})
/** A condition can also match a value inside an array, rather than a field on the global itself. */
test('should update when the condition matches an array entry', async ({ payload }) => {
const original = await payload.updateGlobal({
slug: 'header',
data: { itemsLvl1: [{ label: 'Original' }] },
})
const result = await payload.db.updateGlobal({
slug: 'header',
data: { itemsLvl1: [{ id: original.itemsLvl1?.[0]?.id, label: 'Updated' }] },
where: { 'itemsLvl1.label': { equals: 'Original' } },
})
const stored = await payload.findGlobal({ slug: 'header' })
expect(result?.itemsLvl1).toMatchObject([{ label: 'Updated' }])
expect(stored.itemsLvl1).toMatchObject([{ label: 'Updated' }])
})
})
test('payload.updateGlobal should have globalType, updatedAt, createdAt fields', async ({
payload,
}) => {