Skip to content

Commit 6d3aaaf

Browse files
authored
fix(drizzle): folders with trash enabled don't display documents in polymorphic joins (#14223)
### What? Fixes a bug where collections with both Folders and Trash features enabled fail to display documents in folder views when using the `Postgres` database adapter. ### Why? When querying polymorphic joins (like the `documentsAndFolders` field on folders), two issues occurred: 1. **SQL Type Mismatch**: Collections without trash would select `null` (treated as TEXT type) for `deletedAt`, while collections with trash would select the actual `deleted_at` column (TIMESTAMP type). This caused Postgres UNION queries to fail with: `UNION types text and timestamp with time zone cannot be matched` 2. **Incorrect Operator Mapping**: The `deletedAt: { exists: false }` WHERE constraint was being translated to `IS NOT NULL` instead of `IS NULL` in the simplified `buildSQLWhere` function used for polymorphic joins. This caused non-trashed documents to be filtered out instead of trashed ones. ### How? 1. Added type casting in `traverseFields.ts` to cast NULL values to `timestamp with time zone` for the `deletedAt` field in polymorphic join UNION queries, ensuring type consistency across all collections. 2. Added operator mapping logic in `buildSQLWhere` to convert `exists: false` to the `isNull` operator, duplicating the logic from `sanitizeQueryValue.ts`. This is necessary because `buildSQLWhere` is a simplified WHERE builder for polymorphic joins that doesn't have access to field definitions. Fixes #14198
1 parent 24dad01 commit 6d3aaaf

File tree

3 files changed

+88
-3
lines changed

3 files changed

+88
-3
lines changed

packages/drizzle/src/find/traverseFields.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,13 +59,22 @@ const buildSQLWhere = (where: Where, alias: string) => {
5959
return op(...accumulated)
6060
}
6161
} else {
62-
const payloadOperator = Object.keys(where[k])[0]
62+
let payloadOperator = Object.keys(where[k])[0]
6363

6464
const value = where[k][payloadOperator]
6565
if (payloadOperator === '$raw') {
6666
return sql.raw(value)
6767
}
6868

69+
// Handle exists: false -> use isNull instead of isNotNull
70+
71+
// This logic is duplicated from sanitizeQueryValue.ts because buildSQLWhere
72+
// is a simplified WHERE builder for polymorphic joins that doesn't have access
73+
// to field definitions needed by sanitizeQueryValue
74+
if (payloadOperator === 'exists' && value === false) {
75+
payloadOperator = 'isNull'
76+
}
77+
6978
return operatorMap[payloadOperator](sql.raw(`"${alias}"."${k.split('.').join('_')}"`), value)
7079
}
7180
}
@@ -544,7 +553,13 @@ export const traverseFields = ({
544553
selectFields[path] = sql`${adapter.tables[joinCollectionTableName][path]}`.as(path)
545554
// Allow to filter by collectionSlug
546555
} else if (path !== 'relationTo') {
547-
selectFields[path] = sql`null`.as(path)
556+
// For timestamp fields like deletedAt, we need to cast to timestamp in Postgres
557+
// SQLite doesn't require explicit type casting for UNION queries
558+
if (path === 'deletedAt' && adapter.name === 'postgres') {
559+
selectFields[path] = sql`null::timestamp with time zone`.as(path)
560+
} else {
561+
selectFields[path] = sql`null`.as(path)
562+
}
548563
}
549564
}
550565

test/folders/collections/Posts/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export const Posts: CollectionConfig = {
88
useAsTitle: 'title',
99
},
1010
folders: true,
11+
trash: true,
1112
fields: [
1213
{
1314
name: 'title',

test/folders/int.spec.ts

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ describe('folders', () => {
8787
})
8888
const childDocument = await payload.create({
8989
collection: 'posts',
90-
data: { title: 'Child Document', folder: parentFolder.id, folderType: ['posts'] },
90+
data: { title: 'Child Document', folder: parentFolder.id },
9191
})
9292
const parentFolderQuery = await payload.findByID({
9393
collection: 'payload-folders',
@@ -152,6 +152,75 @@ describe('folders', () => {
152152

153153
expect(parentFolderQuery.documentsAndFolders?.docs).toHaveLength(2)
154154
})
155+
156+
it('should populate non-trashed documents when collection has both folders and trash enabled', async () => {
157+
const parentFolder = await payload.create({
158+
collection: 'payload-folders',
159+
data: {
160+
folderType: ['posts'],
161+
name: 'Posts Folder',
162+
},
163+
})
164+
165+
await payload.create({
166+
collection: 'posts',
167+
data: {
168+
title: 'Post 1',
169+
folder: parentFolder.id,
170+
},
171+
})
172+
173+
await payload.create({
174+
collection: 'posts',
175+
data: {
176+
title: 'Post 2',
177+
folder: parentFolder.id,
178+
},
179+
})
180+
181+
// Create a post that will be trashed
182+
const post3 = await payload.create({
183+
collection: 'posts',
184+
data: {
185+
title: 'Post 3 (to be trashed)',
186+
folder: parentFolder.id,
187+
},
188+
})
189+
190+
// Trash post3
191+
await payload.delete({
192+
collection: 'posts',
193+
id: post3.id,
194+
})
195+
196+
const parentFolderQuery = await payload.findByID({
197+
collection: 'payload-folders',
198+
id: parentFolder.id,
199+
joins: {
200+
documentsAndFolders: {
201+
where: {
202+
or: [
203+
{
204+
deletedAt: {
205+
exists: false,
206+
},
207+
},
208+
],
209+
},
210+
},
211+
},
212+
})
213+
214+
// Should only see 2 non-trashed posts, not the trashed one
215+
expect(parentFolderQuery.documentsAndFolders?.docs).toHaveLength(2)
216+
217+
// Verify the correct posts are returned
218+
const returnedDocs = parentFolderQuery.documentsAndFolders?.docs
219+
expect(returnedDocs).toHaveLength(2)
220+
221+
expect(returnedDocs?.some((doc) => (doc.value as any).title === 'Post 1')).toBe(true)
222+
expect(returnedDocs?.some((doc) => (doc.value as any).title === 'Post 2')).toBe(true)
223+
})
155224
})
156225

157226
describe('hooks', () => {

0 commit comments

Comments
 (0)