Skip to content

Commit 4513a05

Browse files
authored
fix: hasMany text fields cannot be filtered with contains operator (#15671)
### What Fixed `contains` operator for hasMany text fields in MongoDB and Drizzle adapters. ### Why Filtering hasMany text fields with `contains` failed with `TypeError: formattedValue.replace is not a function` because the code didn't handle array values. ### How - MongoDB: Use `$elemMatch` with regex to search within array elements - Drizzle: Handle array values by creating OR conditions with LIKE matching Fixes #15662
1 parent 7d1e233 commit 4513a05

File tree

6 files changed

+223
-8
lines changed

6 files changed

+223
-8
lines changed

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

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -450,9 +450,49 @@ export const sanitizeQueryValue = ({
450450

451451
if (path !== '_id' || (path === '_id' && hasCustomID && field.type === 'text')) {
452452
if (operator === 'contains' && !Types.ObjectId.isValid(formattedValue)) {
453-
formattedValue = {
454-
$options: 'i',
455-
$regex: formattedValue.replace(/[\\^$*+?.()|[\]{}]/g, '\\$&'),
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
459+
if (typeof formattedValue === 'string') {
460+
// Search for documents where any array element contains this string
461+
const escapedValue = formattedValue.replace(/[\\^$*+?.()|[\]{}]/g, '\\$&')
462+
return {
463+
rawQuery: {
464+
[path]: {
465+
$elemMatch: {
466+
$options: 'i',
467+
$regex: escapedValue,
468+
},
469+
},
470+
},
471+
}
472+
} else if (Array.isArray(formattedValue)) {
473+
// Search for documents where any array element contains any of the search values
474+
return {
475+
rawQuery: {
476+
$or: formattedValue.map((val) => {
477+
const escapedValue = String(val).replace(/[\\^$*+?.()|[\]{}]/g, '\\$&')
478+
return {
479+
[path]: {
480+
$elemMatch: {
481+
$options: 'i',
482+
$regex: escapedValue,
483+
},
484+
},
485+
}
486+
}),
487+
},
488+
}
489+
}
490+
} else {
491+
// Regular (non-hasMany) text field
492+
formattedValue = {
493+
$options: 'i',
494+
$regex: formattedValue.replace(/[\\^$*+?.()|[\]{}]/g, '\\$&'),
495+
}
456496
}
457497
}
458498

packages/drizzle/src/queries/parseParams.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,27 @@ export function parseParams({
390390
resolvedQueryValue = queryValue.filter((v) => v !== null)
391391
}
392392

393+
if (
394+
operator === 'contains' &&
395+
Array.isArray(queryValue) &&
396+
'hasMany' in field &&
397+
field.hasMany &&
398+
['number', 'select', 'text'].includes(field.type)
399+
) {
400+
// Create OR conditions for each value in the array
401+
orConditions.push(
402+
...queryValue.map((val) =>
403+
adapter.operators[queryOperator](resolvedColumn, val),
404+
),
405+
)
406+
// Set constraint to combine all OR conditions
407+
const constraint = orConditions.length > 0 ? or(...orConditions) : undefined
408+
if (constraint) {
409+
constraints.push(constraint)
410+
}
411+
break
412+
}
413+
393414
let constraint = adapter.operators[queryOperator](
394415
resolvedColumn,
395416
resolvedQueryValue,

packages/drizzle/src/queries/sanitizeQueryValue.ts

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

241-
if ('hasMany' in field && field.hasMany && operator === 'contains') {
241+
// For hasMany relationship/upload fields, contains should use equals operator
242+
if (
243+
'hasMany' in field &&
244+
field.hasMany &&
245+
operator === 'contains' &&
246+
(field.type === 'relationship' || field.type === 'upload')
247+
) {
242248
operator = 'equals'
243249
}
244250

@@ -249,7 +255,19 @@ export const sanitizeQueryValue = ({
249255
}
250256

251257
if (operator === 'contains') {
252-
formattedValue = `%${formattedValue}%`
258+
// Handle array values for hasMany text/number/select fields
259+
if (
260+
Array.isArray(formattedValue) &&
261+
'hasMany' in field &&
262+
field.hasMany &&
263+
['number', 'select', 'text'].includes(field.type)
264+
) {
265+
// For hasMany text/number/select fields with array values, wrap each element with % for LIKE matching
266+
formattedValue = formattedValue.map((val) => `%${val}%`)
267+
} else if (!Array.isArray(formattedValue)) {
268+
// For non-array values, wrap with % for LIKE matching
269+
formattedValue = `%${formattedValue}%`
270+
}
253271
}
254272

255273
if (operator === 'exists') {

test/fields/collections/Text/e2e.spec.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@ import {
2121
selectTableRow,
2222
} from '../../../__helpers/e2e/helpers.js'
2323
import { AdminUrlUtil } from '../../../__helpers/shared/adminUrlUtil.js'
24-
import { initPayloadE2ENoConfig } from '../../../__helpers/shared/initPayloadE2ENoConfig.js'
2524
import { reInitializeDB } from '../../../__helpers/shared/clearAndSeed/reInitializeDB.js'
25+
import { initPayloadE2ENoConfig } from '../../../__helpers/shared/initPayloadE2ENoConfig.js'
2626
import { RESTClient } from '../../../__helpers/shared/rest.js'
2727
import { TEST_TIMEOUT_LONG } from '../../../playwright.config.js'
2828
import { textFieldsSlug } from '../../slugs.js'
@@ -344,6 +344,43 @@ describe('Text', () => {
344344
await expect(page.locator('table >> tbody >> tr')).toHaveCount(1)
345345
})
346346

347+
test('should filter Text field hasMany: true in the collection list view - contains single value', async () => {
348+
await page.goto(url.list)
349+
await expect(page.locator('table >> tbody >> tr')).toHaveCount(2)
350+
351+
await addListFilter({
352+
page,
353+
fieldLabel: 'Has Many',
354+
operatorLabel: 'contains',
355+
value: 'two',
356+
})
357+
358+
await wait(300)
359+
await expect(page.locator('table >> tbody >> tr')).toHaveCount(1)
360+
})
361+
362+
test('should filter Text field hasMany: true in the collection list view - contains multiple values', async () => {
363+
await page.goto(url.list)
364+
await expect(page.locator('table >> tbody >> tr')).toHaveCount(2)
365+
366+
// Add filter with first value
367+
const { condition } = await addListFilter({
368+
page,
369+
fieldLabel: 'Has Many',
370+
operatorLabel: 'contains',
371+
value: 'one',
372+
})
373+
374+
// Add second value to the same filter
375+
const valueInput = condition.locator('.condition__value input')
376+
await valueInput.click()
377+
await page.keyboard.type('three')
378+
await page.keyboard.press('Enter')
379+
380+
await wait(300)
381+
await expect(page.locator('table >> tbody >> tr')).toHaveCount(2)
382+
})
383+
347384
describe('A11y', () => {
348385
test.fixme('Edit view should have no accessibility violations', async ({}, testInfo) => {
349386
await page.goto(url.create)

test/fields/int.spec.ts

Lines changed: 85 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,10 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect } from 'vi
1010
import type { NextRESTClient } from '../__helpers/shared/NextRESTClient.js'
1111
import type { BlockField, GroupField } from './payload-types.js'
1212

13-
import { devUser } from '../credentials.js'
13+
import { it } from '../__helpers/int/vitest.js'
1414
import { initPayloadInt } from '../__helpers/shared/initPayloadInt.js'
1515
import { isMongoose } from '../__helpers/shared/isMongoose.js'
16-
import { it } from '../__helpers/int/vitest.js'
16+
import { devUser } from '../credentials.js'
1717
import { arrayDefaultValue } from './collections/Array/index.js'
1818
import { blocksDoc } from './collections/Blocks/shared.js'
1919
import { dateDoc } from './collections/Date/shared.js'
@@ -206,6 +206,89 @@ describe('Fields', () => {
206206
expect(missResult).toBeFalsy()
207207
})
208208

209+
it('should query hasMany with contains operator - string value', async () => {
210+
const hit = await payload.create({
211+
collection: 'text-fields',
212+
data: {
213+
hasMany: ['apple pie', 'banana bread', 'cherry tart'],
214+
text: 'required',
215+
},
216+
})
217+
218+
const miss = await payload.create({
219+
collection: 'text-fields',
220+
data: {
221+
hasMany: ['orange juice', 'grape soda'],
222+
text: 'required',
223+
},
224+
})
225+
226+
const { docs } = await payload.find({
227+
collection: 'text-fields',
228+
where: {
229+
hasMany: {
230+
contains: 'banana',
231+
},
232+
},
233+
})
234+
235+
const hitResult = docs.find(({ id: findID }) => hit.id === findID)
236+
const missResult = docs.find(({ id: findID }) => miss.id === findID)
237+
238+
expect(hitResult).toBeDefined()
239+
expect(missResult).toBeFalsy()
240+
241+
await payload.delete({ collection: 'text-fields', id: hit.id })
242+
await payload.delete({ collection: 'text-fields', id: miss.id })
243+
})
244+
245+
it('should query hasMany with contains operator - array value', async () => {
246+
const hit1 = await payload.create({
247+
collection: 'text-fields',
248+
data: {
249+
hasMany: ['apple pie', 'banana bread'],
250+
text: 'required',
251+
},
252+
})
253+
254+
const hit2 = await payload.create({
255+
collection: 'text-fields',
256+
data: {
257+
hasMany: ['cherry tart', 'grape soda'],
258+
text: 'required',
259+
},
260+
})
261+
262+
const miss = await payload.create({
263+
collection: 'text-fields',
264+
data: {
265+
hasMany: ['orange juice', 'lemon water'],
266+
text: 'required',
267+
},
268+
})
269+
270+
const { docs } = await payload.find({
271+
collection: 'text-fields',
272+
where: {
273+
hasMany: {
274+
contains: ['banana', 'cherry'],
275+
},
276+
},
277+
})
278+
279+
const hit1Result = docs.find(({ id: findID }) => hit1.id === findID)
280+
const hit2Result = docs.find(({ id: findID }) => hit2.id === findID)
281+
const missResult = docs.find(({ id: findID }) => miss.id === findID)
282+
283+
expect(hit1Result).toBeDefined()
284+
expect(hit2Result).toBeDefined()
285+
expect(missResult).toBeFalsy()
286+
287+
await payload.delete({ collection: 'text-fields', id: hit1.id })
288+
await payload.delete({ collection: 'text-fields', id: hit2.id })
289+
await payload.delete({ collection: 'text-fields', id: miss.id })
290+
})
291+
209292
it('should query like on value', async () => {
210293
const miss = await payload.create({
211294
collection: 'text-fields',

test/fields/payload-types.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,21 @@ export interface ArrayField {
280280
text: string;
281281
anotherText?: string | null;
282282
localizedText?: string | null;
283+
richTextField?: {
284+
root: {
285+
type: string;
286+
children: {
287+
type: any;
288+
version: number;
289+
[k: string]: unknown;
290+
}[];
291+
direction: ('ltr' | 'rtl') | null;
292+
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
293+
indent: number;
294+
version: number;
295+
};
296+
[k: string]: unknown;
297+
} | null;
283298
subArray?:
284299
| {
285300
text?: string | null;
@@ -2121,6 +2136,7 @@ export interface ArrayFieldsSelect<T extends boolean = true> {
21212136
text?: T;
21222137
anotherText?: T;
21232138
localizedText?: T;
2139+
richTextField?: T;
21242140
subArray?:
21252141
| T
21262142
| {

0 commit comments

Comments
 (0)