Skip to content

Commit 59af156

Browse files
fix(drizzle): convert equals/not_equals to in/not_in for hasMany relationship array values (#15766)
### What? When the value is an array, converts `equals` → `in` and `not_equals` → `not_in` for relationship/upload fields in `sanitizeQueryValue`. ### Why? When filtering relationship fields with `equals` or `not_equals` using array values (e.g., `{ relatedPost: { equals: [1] } }`), SQL adapters (Postgres, SQLite) throw because the `=` operator doesn't accept arrays. ### How? Added an `Array.isArray(formattedValue)` guard at the end of `sanitizeQueryValue` that swaps the operator when the value is an array. It runs after all type conversions (UUID validation, date formatting, ID casting) so it works with already-sanitized values. Same pattern as the existing `contains` → `equals` swap for hasMany relationships on line 248. Also added integration tests covering `equals` and `not_equals` with array values for both hasMany and non-hasMany relationship fields. --------- Co-authored-by: Patrik Kozak <35232443+PatrikKozak@users.noreply.github.com>
1 parent 4931458 commit 59af156

3 files changed

Lines changed: 105 additions & 1 deletion

File tree

packages/drizzle/src/queries/parseParams.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -316,7 +316,7 @@ export function parseParams({
316316
if (
317317
(field.type === 'relationship' || field.type === 'upload') &&
318318
Array.isArray(queryValue) &&
319-
operator === 'not_in'
319+
queryOperator === 'not_in'
320320
) {
321321
constraints.push(
322322
sql`(${notInArray(table[columnName], queryValue)} OR

packages/drizzle/src/queries/sanitizeQueryValue.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,22 @@ export const sanitizeQueryValue = ({
281281
}
282282
}
283283

284+
if ((field.type === 'relationship' || field.type === 'upload') && Array.isArray(formattedValue)) {
285+
if (operator === 'equals') {
286+
return {
287+
columns: formattedColumns,
288+
operator: 'in',
289+
value: formattedValue,
290+
}
291+
} else if (operator === 'not_equals') {
292+
return {
293+
columns: formattedColumns,
294+
operator: 'not_in',
295+
value: formattedValue,
296+
}
297+
}
298+
}
299+
284300
return {
285301
columns: formattedColumns,
286302
operator,

test/fields/int.spec.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4272,6 +4272,94 @@ describe('Fields', () => {
42724272
expect(noMatchDocIDs).toContain(relDoc1.id)
42734273
expect(noMatchDocIDs).not.toContain(relDoc2.id)
42744274
})
4275+
4276+
it('should not throw when querying hasMany relationship with equals array', async () => {
4277+
const text1 = await payload.create({
4278+
collection: 'text-fields',
4279+
data: { text: 'Text 1' },
4280+
})
4281+
4282+
const relDoc = await payload.create({
4283+
collection: 'relationship-fields',
4284+
data: {
4285+
relationshipHasMany: [text1.id],
4286+
relationship: { relationTo: 'text-fields', value: text1.id },
4287+
},
4288+
})
4289+
createdIDs.push(relDoc.id)
4290+
4291+
try {
4292+
const equalsResult = await payload.find({
4293+
collection: 'relationship-fields',
4294+
where: {
4295+
relationshipHasMany: {
4296+
equals: [text1.id],
4297+
},
4298+
},
4299+
})
4300+
4301+
expect(equalsResult.docs.some((doc) => doc.id === relDoc.id)).toBe(true)
4302+
4303+
const notEqualsResult = await payload.find({
4304+
collection: 'relationship-fields',
4305+
where: {
4306+
relationshipHasMany: {
4307+
not_equals: [text1.id],
4308+
},
4309+
},
4310+
})
4311+
4312+
expect(notEqualsResult.docs.some((doc) => doc.id === relDoc.id)).toBe(false)
4313+
} finally {
4314+
await payload.delete({ collection: 'text-fields', id: text1.id })
4315+
}
4316+
})
4317+
4318+
it('should include docs with null relationship when using not_equals with array on non-hasMany field', async () => {
4319+
// Only SQL adapters are affected - MongoDB handles NOT IN / NULL differently
4320+
if (payload.db.name === 'mongoose') {
4321+
return
4322+
}
4323+
4324+
const text1 = await payload.create({
4325+
collection: 'text-fields',
4326+
data: { text: 'Text 1' },
4327+
})
4328+
4329+
const text2 = await payload.create({
4330+
collection: 'text-fields',
4331+
data: { text: 'Text 2' },
4332+
})
4333+
4334+
// relDocWithNull has no relationshipDrawer set (null)
4335+
const relDocWithNull = await payload.create({
4336+
collection: 'relationship-fields',
4337+
data: {
4338+
relationship: { relationTo: 'text-fields', value: text1.id },
4339+
},
4340+
})
4341+
createdIDs.push(relDocWithNull.id)
4342+
4343+
try {
4344+
// Querying not_equals: [text2.id] should include relDocWithNull because
4345+
// its relationshipDrawer is null, which is "not equal to text2.id".
4346+
// Without OR IS NULL in the SQL, NULL NOT IN (text2.id) evaluates to NULL
4347+
// (not TRUE), so the document is incorrectly excluded.
4348+
const result = await payload.find({
4349+
collection: 'relationship-fields',
4350+
where: {
4351+
relationshipDrawer: {
4352+
not_equals: [text2.id],
4353+
},
4354+
},
4355+
})
4356+
4357+
expect(result.docs.some((doc) => doc.id === relDocWithNull.id)).toBe(true)
4358+
} finally {
4359+
await payload.delete({ collection: 'text-fields', id: text1.id })
4360+
await payload.delete({ collection: 'text-fields', id: text2.id })
4361+
}
4362+
})
42754363
})
42764364
})
42774365

0 commit comments

Comments
 (0)