mirror of
https://github.com/payloadcms/payload.git
synced 2026-09-14 20:07:19 +08:00
commit
This commit is contained in:
@@ -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' })
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
Reference in New Issue
Block a user