Skip to content

Commit fba2438

Browse files
authored
fix(drizzle): error when using contains operator on hasMany select fields (#15865)
Fixes `contains` operator on hasMany `select` fields in PostgreSQL. This was a regression introduced in #15671. ## The problem Given a collection with a hasMany `select` field: ```typescript { slug: 'users', fields: [ { name: 'roles', type: 'select', hasMany: true, options: ['user', 'admin', 'editor'], }, ], } ``` Payload stores this across two PostgreSQL tables. For a user created with `roles: ['admin', 'editor']`: **`users`** | id | created_at | updated_at | |----|------------|------------| | 1 | 2026-03-05 | 2026-03-05 | **`users_roles`** | id | parent_id | value | order | |----|-----------|----------|-------| | 1 | 1 | `admin` | 1 | | 2 | 1 | `editor` | 2 | The `value` column is a PostgreSQL **enum type**, not a `varchar`. When querying `{ roles: { contains: 'admin' } }`, Payload generated: ```sql SELECT DISTINCT "users"."id" FROM "users" LEFT JOIN "users_roles" ON "users"."id" = "users_roles"."parent_id" WHERE "users_roles"."value" ILIKE '%admin%' -- ❌ PostgreSQL error: operator does not exist: enum_users_roles ~~* unknown ``` `ILIKE` does not work on PostgreSQL enum types. ## The fix The LEFT JOIN already flattens the array into individual rows: | users.id | users_roles.value | |----------|-------------------| | 1 | `admin` | | 1 | `editor` | So "contains admin" just means "has a row where value equals admin". The fix converts `contains` to `equals` for hasMany `select` fields, the same way it already works for hasMany `relationship` and `upload` fields: ```sql SELECT DISTINCT "users"."id" FROM "users" LEFT JOIN "users_roles" ON "users"."id" = "users_roles"."parent_id" WHERE "users_roles"."value" = 'admin' -- ✅ Matches row 1, returns user id=1 ``` ## Why hasMany `text` fields are different HasMany `text` fields store values as `varchar`, not as an enum. `ILIKE` works fine on `varchar` columns, so substring matching (`contains: 'adm'` matching `'admin'`) is valid there. Whether it's _desirable_ is a problem separate from this PR, being discussed internally [here](https://payloadcms.slack.com/archives/C049BR3QBHC/p1772680331359859)
1 parent 17a0d19 commit fba2438

File tree

5 files changed

+124
-9
lines changed

5 files changed

+124
-9
lines changed

packages/db-mongodb/src/queries/sanitizeQueryValue.ts

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -450,12 +450,25 @@ export const sanitizeQueryValue = ({
450450

451451
if (path !== '_id' || (path === '_id' && hasCustomID && field.type === 'text')) {
452452
if (operator === 'contains' && !Types.ObjectId.isValid(formattedValue)) {
453-
if (
454-
'hasMany' in field &&
455-
field.hasMany &&
456-
['number', 'select', 'text'].includes(field.type)
457-
) {
458-
// For array fields, we need to use $elemMatch to search within array elements
453+
if ('hasMany' in field && field.hasMany && field.type === 'select') {
454+
// For hasMany select, "contains" means the array includes this exact value
455+
if (typeof formattedValue === 'string') {
456+
return {
457+
rawQuery: {
458+
[path]: formattedValue,
459+
},
460+
}
461+
} else if (Array.isArray(formattedValue)) {
462+
return {
463+
rawQuery: {
464+
$or: formattedValue.map((val) => ({
465+
[path]: val,
466+
})),
467+
},
468+
}
469+
}
470+
} else if ('hasMany' in field && field.hasMany && ['number', 'text'].includes(field.type)) {
471+
// For hasMany text/number, "contains" means substring matching within array elements
459472
if (typeof formattedValue === 'string') {
460473
// Search for documents where any array element contains this string
461474
const escapedValue = escapeRegExp(formattedValue)

packages/drizzle/src/queries/sanitizeQueryValue.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -238,12 +238,13 @@ export const sanitizeQueryValue = ({
238238
}
239239
}
240240

241-
// For hasMany relationship/upload fields, contains should use equals operator
241+
// hasMany relationship/upload/select fields are stored as separate rows in a join table.
242+
// The JOIN already gives us individual rows, so "contains" becomes an equality check on each row's value.
242243
if (
243244
'hasMany' in field &&
244245
field.hasMany &&
245246
operator === 'contains' &&
246-
(field.type === 'relationship' || field.type === 'upload')
247+
(field.type === 'relationship' || field.type === 'upload' || field.type === 'select')
247248
) {
248249
operator = 'equals'
249250
}
@@ -260,7 +261,7 @@ export const sanitizeQueryValue = ({
260261
Array.isArray(formattedValue) &&
261262
'hasMany' in field &&
262263
field.hasMany &&
263-
['number', 'select', 'text'].includes(field.type)
264+
['number', 'text'].includes(field.type)
264265
) {
265266
// For hasMany text/number/select fields with array values, wrap each element with % for LIKE matching
266267
formattedValue = formattedValue.map((val) => `%${val}%`)

test/database/getConfig.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1016,6 +1016,23 @@ export const getConfig: () => Partial<Config> = () => ({
10161016
},
10171017
],
10181018
},
1019+
{
1020+
slug: 'select-has-many',
1021+
fields: [
1022+
{
1023+
name: 'roles',
1024+
type: 'select',
1025+
hasMany: true,
1026+
options: ['user', 'admin', 'editor'],
1027+
},
1028+
{
1029+
name: 'food',
1030+
type: 'select',
1031+
hasMany: true,
1032+
options: ['apple', 'bananabread', 'banana'],
1033+
},
1034+
],
1035+
},
10191036
],
10201037
globals: [
10211038
{

test/database/int.spec.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -692,6 +692,50 @@ describe('database', () => {
692692
})
693693
})
694694

695+
it('should query hasMany select field with contains operator', async () => {
696+
const { id } = await payload.create({
697+
collection: 'select-has-many',
698+
data: {
699+
roles: ['admin'],
700+
},
701+
})
702+
703+
const result = await payload.find({
704+
collection: 'select-has-many',
705+
where: {
706+
roles: {
707+
contains: 'admin',
708+
},
709+
},
710+
})
711+
expect(result.docs).toHaveLength(1)
712+
713+
expect(result.docs.some((doc) => doc.id === id)).toBe(true)
714+
715+
await payload.delete({ collection: 'select-has-many', id })
716+
})
717+
718+
it('ensure querying hasMany select field with contains operator does not do partial matching', async () => {
719+
const { id } = await payload.create({
720+
collection: 'select-has-many',
721+
data: {
722+
food: ['bananabread'],
723+
},
724+
})
725+
726+
const result = await payload.find({
727+
collection: 'select-has-many',
728+
where: {
729+
food: {
730+
contains: 'banana',
731+
},
732+
},
733+
})
734+
expect(result.docs).toHaveLength(0)
735+
736+
await payload.delete({ collection: 'select-has-many', id })
737+
})
738+
695739
describe('allow ID on create', () => {
696740
beforeAll(() => {
697741
payload.db.allowIDOnCreate = true

test/database/payload-types.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ export interface Config {
8989
aliases: Alias;
9090
'blocks-docs': BlocksDoc;
9191
'unique-fields': UniqueField;
92+
'select-has-many': SelectHasMany;
9293
'payload-kv': PayloadKv;
9394
users: User;
9495
'payload-locked-documents': PayloadLockedDocument;
@@ -119,6 +120,7 @@ export interface Config {
119120
aliases: AliasesSelect<false> | AliasesSelect<true>;
120121
'blocks-docs': BlocksDocsSelect<false> | BlocksDocsSelect<true>;
121122
'unique-fields': UniqueFieldsSelect<false> | UniqueFieldsSelect<true>;
123+
'select-has-many': SelectHasManySelect<false> | SelectHasManySelect<true>;
122124
'payload-kv': PayloadKvSelect<false> | PayloadKvSelect<true>;
123125
users: UsersSelect<false> | UsersSelect<true>;
124126
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
@@ -144,6 +146,9 @@ export interface Config {
144146
'virtual-relation-global': VirtualRelationGlobalSelect<false> | VirtualRelationGlobalSelect<true>;
145147
};
146148
locale: 'en' | 'es' | 'uk';
149+
widgets: {
150+
collections: CollectionsWidget;
151+
};
147152
user: User;
148153
jobs: {
149154
tasks: unknown;
@@ -693,6 +698,17 @@ export interface UniqueField {
693698
updatedAt: string;
694699
createdAt: string;
695700
}
701+
/**
702+
* This interface was referenced by `Config`'s JSON-Schema
703+
* via the `definition` "select-has-many".
704+
*/
705+
export interface SelectHasMany {
706+
id: string;
707+
roles?: ('user' | 'admin' | 'editor')[] | null;
708+
food?: ('apple' | 'bananabread' | 'banana')[] | null;
709+
updatedAt: string;
710+
createdAt: string;
711+
}
696712
/**
697713
* This interface was referenced by `Config`'s JSON-Schema
698714
* via the `definition` "payload-kv".
@@ -830,6 +846,10 @@ export interface PayloadLockedDocument {
830846
relationTo: 'unique-fields';
831847
value: string | UniqueField;
832848
} | null)
849+
| ({
850+
relationTo: 'select-has-many';
851+
value: string | SelectHasMany;
852+
} | null)
833853
| ({
834854
relationTo: 'users';
835855
value: string | User;
@@ -1330,6 +1350,16 @@ export interface UniqueFieldsSelect<T extends boolean = true> {
13301350
updatedAt?: T;
13311351
createdAt?: T;
13321352
}
1353+
/**
1354+
* This interface was referenced by `Config`'s JSON-Schema
1355+
* via the `definition` "select-has-many_select".
1356+
*/
1357+
export interface SelectHasManySelect<T extends boolean = true> {
1358+
roles?: T;
1359+
food?: T;
1360+
updatedAt?: T;
1361+
createdAt?: T;
1362+
}
13331363
/**
13341364
* This interface was referenced by `Config`'s JSON-Schema
13351365
* via the `definition` "payload-kv_select".
@@ -1540,6 +1570,16 @@ export interface VirtualRelationGlobalSelect<T extends boolean = true> {
15401570
createdAt?: T;
15411571
globalType?: T;
15421572
}
1573+
/**
1574+
* This interface was referenced by `Config`'s JSON-Schema
1575+
* via the `definition` "collections_widget".
1576+
*/
1577+
export interface CollectionsWidget {
1578+
data?: {
1579+
[k: string]: unknown;
1580+
};
1581+
width: 'full';
1582+
}
15431583
/**
15441584
* This interface was referenced by `Config`'s JSON-Schema
15451585
* via the `definition` "auth".

0 commit comments

Comments
 (0)