fix(plugin-import-export): flattening logic for polymorphic relationships in CSV exports (#13094)

### What?

Improves the flattening logic used in the import-export plugin to
correctly handle polymorphic relationships (both `hasOne` and `hasMany`)
when generating CSV columns.

### Why?

Previously, `hasMany` polymorphic relationships would flatten their full
`value` object recursively, resulting in unwanted keys like `createdAt`,
`title`, `email`, etc. This change ensures that only the `id` and
`relationTo` fields are included, matching how `hasOne` polymorphic
fields already behave.

### How?

- Updated `flattenObject` to special-case `hasMany` polymorphic
relationships and extract only `relationTo` and `id` per index.
- Refined `getFlattenedFieldKeys` to return correct column keys for
polymorphic fields:
  - `hasMany polymorphic → name_0_relationTo`, `name_0_id`
  - `hasOne polymorphic → name_relationTo`, `name_id`
  - `monomorphic → name` or `name_0`
- **Added try/catch blocks** around `toCSVFunctions` calls in
`flattenObject`, with descriptive error messages including the column
path and input value. This improves debuggability if a custom `toCSV`
function throws.
This commit is contained in:
Patrik
2025-07-09 15:46:48 -04:00
committed by GitHub
parent 0806ee1762
commit c6105f1e0d
6 changed files with 86 additions and 55 deletions

View File

@@ -106,7 +106,6 @@ export const createExport = async (args: CreateExportArgs) => {
const toCSVFunctions = getCustomFieldFunctions({
fields: collectionConfig.flattenedFields,
select,
})
if (download) {

View File

@@ -24,10 +24,23 @@ export const flattenObject = ({
if (Array.isArray(value)) {
value.forEach((item, index) => {
if (typeof item === 'object' && item !== null) {
// Case: hasMany polymorphic relationships
if (
'relationTo' in item &&
'value' in item &&
typeof item.value === 'object' &&
item.value !== null
) {
row[`${`${newKey}_${index}`}_relationTo`] = item.relationTo
row[`${`${newKey}_${index}`}_id`] = item.value.id
return
}
flatten(item, `${newKey}_${index}`)
} else {
if (toCSVFunctions?.[newKey]) {
const columnName = `${newKey}_${index}`
try {
const result = toCSVFunctions[newKey]({
columnName,
data: row,
@@ -39,6 +52,11 @@ export const flattenObject = ({
if (typeof result !== 'undefined') {
row[columnName] = result
}
} catch (error) {
throw new Error(
`Error in toCSVFunction for array item "${columnName}": ${JSON.stringify(item)}\n${(error as Error).message}`,
)
}
} else {
row[`${newKey}_${index}`] = item
}
@@ -48,6 +66,7 @@ export const flattenObject = ({
if (!toCSVFunctions?.[newKey]) {
flatten(value, newKey)
} else {
try {
const result = toCSVFunctions[newKey]({
columnName: newKey,
data: row,
@@ -59,9 +78,15 @@ export const flattenObject = ({
if (typeof result !== 'undefined') {
row[newKey] = result
}
} catch (error) {
throw new Error(
`Error in toCSVFunction for nested object "${newKey}": ${JSON.stringify(value)}\n${(error as Error).message}`,
)
}
}
} else {
if (toCSVFunctions?.[newKey]) {
try {
const result = toCSVFunctions[newKey]({
columnName: newKey,
data: row,
@@ -73,6 +98,11 @@ export const flattenObject = ({
if (typeof result !== 'undefined') {
row[newKey] = result
}
} catch (error) {
throw new Error(
`Error in toCSVFunction for field "${newKey}": ${JSON.stringify(value)}\n${(error as Error).message}`,
)
}
} else {
row[newKey] = value
}

View File

@@ -1,21 +1,12 @@
import {
type FlattenedField,
type SelectIncludeType,
traverseFields,
type TraverseFieldsCallback,
} from 'payload'
import { type FlattenedField, traverseFields, type TraverseFieldsCallback } from 'payload'
import type { ToCSVFunction } from '../types.js'
type Args = {
fields: FlattenedField[]
select: SelectIncludeType | undefined
}
export const getCustomFieldFunctions = ({
fields,
select,
}: Args): Record<string, ToCSVFunction> => {
export const getCustomFieldFunctions = ({ fields }: Args): Record<string, ToCSVFunction> => {
const result: Record<string, ToCSVFunction> = {}
const buildCustomFunctions: TraverseFieldsCallback = ({ field, parentRef, ref }) => {
@@ -54,7 +45,7 @@ export const getCustomFieldFunctions = ({
data[`${ref.prefix}${field.name}_relationTo`] = relationTo
}
}
return undefined
return undefined // prevents further flattening
}
}
} else {
@@ -98,10 +89,6 @@ export const getCustomFieldFunctions = ({
}
}
}
// TODO: do this so we only return the functions needed based on the select used
////@ts-expect-error ref is untyped
// ref.select = typeof select !== 'undefined' || select[field.name] ? select : {}
}
traverseFields({ callback: buildCustomFunctions, fields })

View File

@@ -110,7 +110,6 @@ export const importExportPlugin =
const toCSVFunctions = getCustomFieldFunctions({
fields: collection.config.fields as FlattenedField[],
select,
})
const possibleKeys = getFlattenedFieldKeys(collection.config.fields as FlattenedField[])

View File

@@ -16,7 +16,13 @@ export const getFlattenedFieldKeys = (fields: FieldWithPresentational[], prefix
const keys: string[] = []
fields.forEach((field) => {
if (!('name' in field) || typeof field.name !== 'string') {
const fieldHasToCSVFunction =
'custom' in field &&
typeof field.custom === 'object' &&
'plugin-import-export' in field.custom &&
field.custom['plugin-import-export']?.toCSV
if (!('name' in field) || typeof field.name !== 'string' || fieldHasToCSVFunction) {
return
}
@@ -41,11 +47,21 @@ export const getFlattenedFieldKeys = (fields: FieldWithPresentational[], prefix
break
case 'relationship':
if (field.hasMany) {
// e.g. hasManyPolymorphic_0_value_id
keys.push(`${name}_0_relationTo`, `${name}_0_value_id`)
if (Array.isArray(field.relationTo)) {
// hasMany polymorphic
keys.push(`${name}_0_relationTo`, `${name}_0_id`)
} else {
// e.g. hasOnePolymorphic_id
keys.push(`${name}_id`, `${name}_relationTo`)
// hasMany monomorphic
keys.push(`${name}_0`)
}
} else {
if (Array.isArray(field.relationTo)) {
// hasOne polymorphic
keys.push(`${name}_relationTo`, `${name}_id`)
} else {
// hasOne monomorphic
keys.push(name)
}
}
break
case 'tabs':

View File

@@ -569,9 +569,9 @@ describe('@payloadcms/plugin-import-export', () => {
expect(data[0].hasOnePolymorphic_relationTo).toBe('posts')
// hasManyPolymorphic
expect(data[0].hasManyPolymorphic_0_value_id).toBeDefined()
expect(data[0].hasManyPolymorphic_0_id).toBeDefined()
expect(data[0].hasManyPolymorphic_0_relationTo).toBe('users')
expect(data[0].hasManyPolymorphic_1_value_id).toBeDefined()
expect(data[0].hasManyPolymorphic_1_id).toBeDefined()
expect(data[0].hasManyPolymorphic_1_relationTo).toBe('posts')
})