Skip to content

Commit a0a1e20

Browse files
authored
fix(drizzle): polymorphic querying of different ID types (#8191)
This PR fixes querying by a relationship field that has custom IDs in `relationTo` with different types. Now, in this case, we do cast the ID value in the database. Example of the config / int test that reproduced the issue: ```ts { slug: 'posts-a', fields: [], }, { slug: 'posts-b', fields: [], }, { slug: 'posts-custom-id', fields: [{ name: 'id', type: 'text' }], }, { slug: 'roots', fields: [ { name: 'rel', relationTo: ['posts-a', 'posts-b', 'posts-custom-id'], type: 'relationship', }, ], }, ``` ```ts const postA = await payload.create({ collection: 'posts-a', data: {} }) const postB = await payload.create({ collection: 'posts-b', data: {} }) const postC = await payload.create({ collection: 'posts-custom-id', data: { id: crypto.randomUUID() }, }) const root_1 = await payload.create({ collection: 'roots', data: { rel: { value: postC.id, relationTo: 'posts-custom-id', }, }, }) const res_1 = await payload.find({ collection: 'roots', where: { 'rel.value': { equals: postC.id }, }, }) // COALESCE types integer and character varying cannot be matched expect(res_1.totalDocs).toBe(1) ``` <!-- For external contributors, please include: - A summary of the pull request and any related issues it fixes. - Reasoning for the changes made or any additional context that may be useful. Ensure you have read and understand the [CONTRIBUTING.md](https://github.com/payloadcms/payload/blob/main/CONTRIBUTING.md) document in this repository. -->
1 parent 3b59416 commit a0a1e20

File tree

8 files changed

+296
-37
lines changed

8 files changed

+296
-37
lines changed

packages/drizzle/src/queries/getTableColumnFromPath.ts

Lines changed: 50 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import type { SQL } from 'drizzle-orm'
2-
import type { PgTableWithColumns } from 'drizzle-orm/pg-core'
32
import type { SQLiteTableWithColumns } from 'drizzle-orm/sqlite-core'
43
import type { Field, FieldAffectingData, NumberField, TabAsField, TextField } from 'payload'
54

65
import { and, eq, like, sql } from 'drizzle-orm'
6+
import { type PgTableWithColumns } from 'drizzle-orm/pg-core'
77
import { APIError, flattenTopLevelFields } from 'payload'
88
import { fieldAffectsData, tabHasName } from 'payload/shared'
99
import toSnakeCase from 'to-snake-case'
10+
import { validate as uuidValidate } from 'uuid'
1011

1112
import type { DrizzleAdapter, GenericColumn } from '../types.js'
1213
import type { BuildQueryJoinAliases } from './buildQuery.js'
@@ -21,6 +22,10 @@ type Constraint = {
2122

2223
type TableColumn = {
2324
columnName?: string
25+
columns?: {
26+
idType: 'number' | 'text'
27+
rawColumn: SQL<unknown>
28+
}[]
2429
constraints: Constraint[]
2530
field: FieldAffectingData
2631
getNotNullColumnByValue?: (val: unknown) => string
@@ -53,7 +58,7 @@ type Args = {
5358
value: unknown
5459
}
5560
/**
56-
* Transforms path to table and column name
61+
* Transforms path to table and column name or to a list of OR columns
5762
* Adds tables to `join`
5863
* @returns TableColumn
5964
*/
@@ -510,25 +515,55 @@ export const getTableColumnFromPath = ({
510515
}
511516
}
512517
} else if (newCollectionPath === 'value') {
513-
const tableColumnsNames = field.relationTo.map((relationTo) => {
514-
const relationTableName = adapter.tableNameMap.get(
515-
toSnakeCase(adapter.payload.collections[relationTo].config.slug),
516-
)
518+
const hasCustomCollectionWithCustomID = field.relationTo.some(
519+
(relationTo) => !!adapter.payload.collections[relationTo].customIDType,
520+
)
517521

518-
return `"${aliasRelationshipTableName}"."${relationTableName}_id"`
519-
})
522+
const columns: TableColumn['columns'] = field.relationTo
523+
.map((relationTo) => {
524+
let idType: 'number' | 'text' = adapter.idType === 'uuid' ? 'text' : 'number'
520525

521-
let column: string
522-
if (tableColumnsNames.length === 1) {
523-
column = tableColumnsNames[0]
524-
} else {
525-
column = `COALESCE(${tableColumnsNames.join(', ')})`
526-
}
526+
const { customIDType } = adapter.payload.collections[relationTo]
527+
528+
if (customIDType) {
529+
idType = customIDType
530+
}
531+
532+
// Do not add the column to OR if we know that it can't match by the type
533+
// We can't do the same with idType: 'number' because `value` can be from the REST search query params
534+
if (typeof value === 'number' && idType === 'text') {
535+
return null
536+
}
537+
538+
// Do not add the UUID type column if incoming query value doesn't match UUID. If there aren't any collections with
539+
// a custom ID type, we skip this check
540+
// We need this because Postgres throws an error if querying by UUID column with a value that isn't a valid UUID.
541+
if (
542+
value &&
543+
!customIDType &&
544+
adapter.idType === 'uuid' &&
545+
hasCustomCollectionWithCustomID
546+
) {
547+
if (!uuidValidate(value)) {
548+
return null
549+
}
550+
}
551+
552+
const relationTableName = adapter.tableNameMap.get(
553+
toSnakeCase(adapter.payload.collections[relationTo].config.slug),
554+
)
555+
556+
return {
557+
idType,
558+
rawColumn: sql.raw(`"${aliasRelationshipTableName}"."${relationTableName}_id"`),
559+
}
560+
})
561+
.filter(Boolean)
527562

528563
return {
564+
columns,
529565
constraints,
530566
field,
531-
rawColumn: sql.raw(`${column}`),
532567
table: aliasRelationshipTable,
533568
}
534569
} else if (newCollectionPath === 'relationTo') {

packages/drizzle/src/queries/parseParams.ts

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,8 +67,10 @@ export async function parseParams({
6767
for (let operator of Object.keys(pathOperators)) {
6868
if (validOperators.includes(operator as Operator)) {
6969
const val = where[relationOrPath][operator]
70+
7071
const {
7172
columnName,
73+
columns,
7274
constraints: queryConstraints,
7375
field,
7476
getNotNullColumnByValue,
@@ -190,6 +192,7 @@ export async function parseParams({
190192

191193
const sanitizedQueryValue = sanitizeQueryValue({
192194
adapter,
195+
columns,
193196
field,
194197
operator,
195198
relationOrPath,
@@ -200,7 +203,49 @@ export async function parseParams({
200203
break
201204
}
202205

203-
const { operator: queryOperator, value: queryValue } = sanitizedQueryValue
206+
const {
207+
columns: queryColumns,
208+
operator: queryOperator,
209+
value: queryValue,
210+
} = sanitizedQueryValue
211+
212+
// Handle polymorphic relationships by value
213+
if (queryColumns) {
214+
if (!queryColumns.length) {
215+
break
216+
}
217+
218+
let wrapOperator = or
219+
220+
if (queryValue === null && ['equals', 'not_equals'].includes(operator)) {
221+
if (operator === 'equals') {
222+
wrapOperator = and
223+
}
224+
225+
constraints.push(
226+
wrapOperator(
227+
...queryColumns.map(({ rawColumn }) =>
228+
operator === 'equals' ? isNull(rawColumn) : isNotNull(rawColumn),
229+
),
230+
),
231+
)
232+
break
233+
}
234+
235+
if (['not_equals', 'not_in'].includes(operator)) {
236+
wrapOperator = and
237+
}
238+
239+
constraints.push(
240+
wrapOperator(
241+
...queryColumns.map(({ rawColumn, value }) =>
242+
adapter.operators[queryOperator](rawColumn, value),
243+
),
244+
),
245+
)
246+
247+
break
248+
}
204249

205250
if (queryOperator === 'not_equals' && queryValue !== null) {
206251
constraints.push(

packages/drizzle/src/queries/sanitizeQueryValue.ts

Lines changed: 43 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,42 @@
1+
import type { SQL } from 'drizzle-orm'
2+
13
import { APIError, createArrayFromCommaDelineated, type Field, type TabAsField } from 'payload'
24
import { fieldAffectsData } from 'payload/shared'
35

46
import type { DrizzleAdapter } from '../types.js'
57

68
type SanitizeQueryValueArgs = {
79
adapter: DrizzleAdapter
10+
columns?: {
11+
idType: 'number' | 'text'
12+
rawColumn: SQL<unknown>
13+
}[]
814
field: Field | TabAsField
915
operator: string
1016
relationOrPath: string
1117
val: any
1218
}
1319

20+
type SanitizedColumn = {
21+
rawColumn: SQL<unknown>
22+
value: unknown
23+
}
24+
1425
export const sanitizeQueryValue = ({
1526
adapter,
27+
columns,
1628
field,
1729
operator: operatorArg,
1830
relationOrPath,
1931
val,
20-
}: SanitizeQueryValueArgs): { operator: string; value: unknown } => {
32+
}: SanitizeQueryValueArgs): {
33+
columns?: SanitizedColumn[]
34+
operator: string
35+
value: unknown
36+
} => {
2137
let operator = operatorArg
2238
let formattedValue = val
39+
let formattedColumns: SanitizedColumn[]
2340

2441
if (!fieldAffectsData(field)) {
2542
return { operator, value: formattedValue }
@@ -100,10 +117,26 @@ export const sanitizeQueryValue = ({
100117
}
101118
idType = typeMap[mixedType]
102119
} else {
103-
// LIMITATION: Only cast to the first relationTo id type,
104-
// otherwise we need to make the db cast which is inefficient
105-
const collection = adapter.payload.collections[field.relationTo[0]]
106-
idType = collection.customIDType || adapter.idType === 'uuid' ? 'text' : 'number'
120+
formattedColumns = columns
121+
.map(({ idType, rawColumn }) => {
122+
let formattedValue: number | string
123+
if (idType === 'number') {
124+
formattedValue = Number(val)
125+
126+
if (Number.isNaN(formattedValue)) {
127+
return null
128+
}
129+
}
130+
if (idType === 'text') {
131+
formattedValue = String(val)
132+
}
133+
134+
return {
135+
rawColumn,
136+
value: formattedValue,
137+
}
138+
})
139+
.filter(Boolean)
107140
}
108141
if (Array.isArray(formattedValue)) {
109142
formattedValue = formattedValue.map((value) => {
@@ -147,5 +180,9 @@ export const sanitizeQueryValue = ({
147180
}
148181
}
149182

150-
return { operator, value: formattedValue }
183+
return {
184+
columns: formattedColumns,
185+
operator,
186+
value: formattedValue,
187+
}
151188
}

test/collections-rest/int.spec.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,9 @@ describe('collections-rest', () => {
3333
if (payload.db.name === 'mongoose') {
3434
await new Promise((resolve, reject) => {
3535
payload.db.collections[pointSlug].ensureIndexes(function (err) {
36-
if (err) reject(err)
36+
if (err) {
37+
reject(err)
38+
}
3739
resolve(true)
3840
})
3941
})

test/databaseAdapter.ts

Lines changed: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,13 @@
1+
// DO NOT MODIFY. This file is automatically generated in initDevAndTest.ts
12

2-
// DO NOT MODIFY. This file is automatically generated in initDevAndTest.ts
3+
import { mongooseAdapter } from '@payloadcms/db-mongodb'
34

4-
5-
import { mongooseAdapter } from '@payloadcms/db-mongodb'
6-
7-
export const databaseAdapter = mongooseAdapter({
8-
url:
9-
process.env.MONGODB_MEMORY_SERVER_URI ||
10-
process.env.DATABASE_URI ||
11-
'mongodb://127.0.0.1/payloadtests',
12-
collation: {
13-
strength: 1,
14-
},
15-
})
16-
5+
export const databaseAdapter = mongooseAdapter({
6+
url:
7+
process.env.MONGODB_MEMORY_SERVER_URI ||
8+
process.env.DATABASE_URI ||
9+
'mongodb://127.0.0.1/payloadtests',
10+
collation: {
11+
strength: 1,
12+
},
13+
})

test/relationships/config.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,16 @@ export default buildConfigWithDefaults({
315315
},
316316
],
317317
},
318+
{
319+
slug: 'rels-to-pages-and-custom-text-ids',
320+
fields: [
321+
{
322+
name: 'rel',
323+
type: 'relationship',
324+
relationTo: ['pages', 'custom-id', 'custom-id-number'],
325+
},
326+
],
327+
},
318328
],
319329
onInit: async (payload) => {
320330
await payload.create({

0 commit comments

Comments
 (0)