Skip to content

Commit aa143e6

Browse files
authored
fix: trashed documents still show as IDs in relationship responses (#16102)
# Overview When a relationship points to a collection with `trash: true`, soft-deleted documents were showing as raw IDs in responses instead of being omitted. ## Key Changes In the dataloader, trash-enabled collections now fetch with `trash: true` so soft-deleted documents are returned alongside live ones. `deletedAt` is merged into the select to ensure it's always present in the result. In `populate()`, the returned doc is checked for `deletedAt`. If set, the doc is trashed and `relationshipValue` is set to null. For hasMany, null entries are filtered from the array after all row promises settle. For single relationships, the field is set to null. This correctly distinguishes trashed documents from inaccessible ones — access-denied or permanently deleted documents still fall back to the raw ID, unchanged from existing behavior. `depth: 0` is also unaffected. Fixes #16022 --- - To see the specific tasks where the Asana app for GitHub is being used, see below: - https://app.asana.com/0/0/1213860470196517
1 parent 43b8de6 commit aa143e6

5 files changed

Lines changed: 146 additions & 2 deletions

File tree

packages/payload/src/collections/dataloader.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,9 @@ const batchAndLoadDocs =
107107

108108
req.transactionID = transactionID
109109

110+
const enableTrash = Boolean(payload.collections?.[collection]?.config?.trash)
111+
const selectWithDeletedAt = enableTrash && select ? { ...select, deletedAt: true } : select
112+
110113
const result = await payload.find({
111114
collection,
112115
currentDepth,
@@ -119,8 +122,9 @@ const batchAndLoadDocs =
119122
pagination: false,
120123
populate,
121124
req,
122-
select,
125+
select: selectWithDeletedAt,
123126
showHiddenFields: Boolean(showHiddenFields),
127+
...(enableTrash ? { trash: true } : {}),
124128
where: {
125129
id: {
126130
in: ids,

packages/payload/src/fields/hooks/afterRead/relationshipPopulationPromise.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,11 @@ const populate = async ({
9494
)
9595
}
9696

97-
if (!relationshipValue) {
97+
if (relatedCollection.config.trash && relationshipValue) {
98+
if ((relationshipValue as Record<string, unknown>).deletedAt) {
99+
relationshipValue = null
100+
}
101+
} else if (!relationshipValue) {
98102
// ids are visible regardless of access controls
99103
relationshipValue = id
100104
}
@@ -276,4 +280,25 @@ export const relationshipPopulationPromise = async ({
276280
})
277281
}
278282
await Promise.all(rowPromises)
283+
284+
if (field.type !== 'join' && fieldSupportsMany(field) && field.hasMany) {
285+
const notNull = Array.isArray(field.relationTo)
286+
? (v: unknown) => v !== null && (v as Record<string, unknown>)?.value !== null
287+
: (v: unknown) => v !== null
288+
289+
if (
290+
fieldShouldBeLocalized({ field, parentIsLocalized }) &&
291+
locale === 'all' &&
292+
typeof resultingDoc[field.name] === 'object' &&
293+
resultingDoc[field.name] !== null
294+
) {
295+
for (const localeKey of Object.keys(resultingDoc[field.name])) {
296+
if (Array.isArray(resultingDoc[field.name][localeKey])) {
297+
resultingDoc[field.name][localeKey] = resultingDoc[field.name][localeKey].filter(notNull)
298+
}
299+
}
300+
} else if (Array.isArray(resultingDoc[field.name])) {
301+
resultingDoc[field.name] = (resultingDoc[field.name] as unknown[]).filter(notNull)
302+
}
303+
}
279304
}

test/trash/collections/Pages/index.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import type { CollectionConfig } from 'payload'
22

3+
import { postsSlug } from '../Posts/index.js'
4+
35
export const pagesSlug = 'pages'
46

57
export const Pages: CollectionConfig = {
@@ -13,6 +15,17 @@ export const Pages: CollectionConfig = {
1315
name: 'title',
1416
type: 'text',
1517
},
18+
{
19+
name: 'relatedPosts',
20+
type: 'relationship',
21+
relationTo: postsSlug,
22+
hasMany: true,
23+
},
24+
{
25+
name: 'featuredPost',
26+
type: 'relationship',
27+
relationTo: postsSlug,
28+
},
1629
],
1730
versions: {
1831
drafts: true,

test/trash/int.spec.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { idToString } from '../__helpers/shared/idToString.js'
1111
import { initPayloadInt } from '../__helpers/shared/initPayloadInt.js'
1212
import { devUser, regularUser } from '../credentials.js'
1313
import { differentiatedTrashCollectionSlug } from './collections/DifferentiatedTrashCollection/index.js'
14+
import { pagesSlug } from './collections/Pages/index.js'
1415
import { postsSlug } from './collections/Posts/index.js'
1516
import { restrictedCollectionSlug } from './collections/RestrictedCollection/index.js'
1617
import { usersSlug } from './collections/Users/index.js'
@@ -2218,4 +2219,101 @@ describe('trash', () => {
22182219
})
22192220
})
22202221
})
2222+
2223+
describe('Relationship population', () => {
2224+
const createdPageIDs: (number | string)[] = []
2225+
2226+
afterEach(async () => {
2227+
for (const id of createdPageIDs) {
2228+
await payload.delete({ collection: pagesSlug, id })
2229+
}
2230+
createdPageIDs.length = 0
2231+
})
2232+
2233+
it('should not include trashed document IDs in hasMany relationship population', async () => {
2234+
// postsDocOne is non-trashed, postsDocTwo is trashed
2235+
const page = await payload.create({
2236+
collection: pagesSlug,
2237+
data: {
2238+
title: 'Page with related posts',
2239+
relatedPosts: [postsDocOne.id, postsDocTwo.id],
2240+
},
2241+
})
2242+
createdPageIDs.push(page.id)
2243+
2244+
const result = await payload.findByID({
2245+
collection: pagesSlug,
2246+
id: page.id,
2247+
depth: 1,
2248+
})
2249+
2250+
// The trashed post (postsDocTwo) should be absent from the relationship array
2251+
// Non-trashed post (postsDocOne) should be populated as an object
2252+
expect(Array.isArray(result.relatedPosts)).toBe(true)
2253+
expect(result.relatedPosts).toHaveLength(1)
2254+
expect((result.relatedPosts as Post[])[0]?.id).toBe(postsDocOne.id)
2255+
})
2256+
2257+
it('should return null for a trashed document in a single relationship', async () => {
2258+
const page = await payload.create({
2259+
collection: pagesSlug,
2260+
data: {
2261+
title: 'Page with featured post',
2262+
featuredPost: postsDocTwo.id,
2263+
},
2264+
})
2265+
createdPageIDs.push(page.id)
2266+
2267+
const result = await payload.findByID({
2268+
collection: pagesSlug,
2269+
id: page.id,
2270+
depth: 1,
2271+
})
2272+
2273+
expect(result.featuredPost).toBeNull()
2274+
})
2275+
2276+
it('should populate a non-trashed document in a single relationship', async () => {
2277+
const page = await payload.create({
2278+
collection: pagesSlug,
2279+
data: {
2280+
title: 'Page with featured post',
2281+
featuredPost: postsDocOne.id,
2282+
},
2283+
})
2284+
createdPageIDs.push(page.id)
2285+
2286+
const result = await payload.findByID({
2287+
collection: pagesSlug,
2288+
id: page.id,
2289+
depth: 1,
2290+
})
2291+
2292+
expect((result.featuredPost as Post)?.id).toBe(postsDocOne.id)
2293+
})
2294+
2295+
it('should include trashed documents in relationship when depth=0', async () => {
2296+
// At depth=0, relationships are returned as IDs - but trashed IDs should still be filtered
2297+
const page = await payload.create({
2298+
collection: pagesSlug,
2299+
data: {
2300+
title: 'Page with related posts depth 0',
2301+
relatedPosts: [postsDocOne.id, postsDocTwo.id],
2302+
},
2303+
})
2304+
createdPageIDs.push(page.id)
2305+
2306+
const result = await payload.findByID({
2307+
collection: pagesSlug,
2308+
id: page.id,
2309+
depth: 0,
2310+
})
2311+
2312+
// At depth=0, no population occurs - raw IDs are returned as stored
2313+
// The trashed post ID should still be visible at depth=0
2314+
const relatedPosts = result.relatedPosts as (number | string)[]
2315+
expect(Array.isArray(relatedPosts)).toBe(true)
2316+
expect(relatedPosts).toHaveLength(2)
2317+
})
2318+
})
22212319
})

test/trash/payload-types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,8 @@ export interface UserAuthOperations {
130130
export interface Page {
131131
id: string;
132132
title?: string | null;
133+
relatedPosts?: (string | Post)[] | null;
134+
featuredPost?: (string | null) | Post;
133135
updatedAt: string;
134136
createdAt: string;
135137
_status?: ('draft' | 'published') | null;
@@ -291,6 +293,8 @@ export interface PayloadMigration {
291293
*/
292294
export interface PagesSelect<T extends boolean = true> {
293295
title?: T;
296+
relatedPosts?: T;
297+
featuredPost?: T;
294298
updatedAt?: T;
295299
createdAt?: T;
296300
_status?: T;

0 commit comments

Comments
 (0)