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({ const toCSVFunctions = getCustomFieldFunctions({
fields: collectionConfig.flattenedFields, fields: collectionConfig.flattenedFields,
select,
}) })
if (download) { if (download) {

View File

@@ -24,20 +24,38 @@ export const flattenObject = ({
if (Array.isArray(value)) { if (Array.isArray(value)) {
value.forEach((item, index) => { value.forEach((item, index) => {
if (typeof item === 'object' && item !== null) { 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}`) flatten(item, `${newKey}_${index}`)
} else { } else {
if (toCSVFunctions?.[newKey]) { if (toCSVFunctions?.[newKey]) {
const columnName = `${newKey}_${index}` const columnName = `${newKey}_${index}`
const result = toCSVFunctions[newKey]({ try {
columnName, const result = toCSVFunctions[newKey]({
data: row, columnName,
doc, data: row,
row, doc,
siblingDoc, row,
value: item, siblingDoc,
}) value: item,
if (typeof result !== 'undefined') { })
row[columnName] = result 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 { } else {
row[`${newKey}_${index}`] = item row[`${newKey}_${index}`] = item
@@ -48,30 +66,42 @@ export const flattenObject = ({
if (!toCSVFunctions?.[newKey]) { if (!toCSVFunctions?.[newKey]) {
flatten(value, newKey) flatten(value, newKey)
} else { } else {
const result = toCSVFunctions[newKey]({ try {
columnName: newKey, const result = toCSVFunctions[newKey]({
data: row, columnName: newKey,
doc, data: row,
row, doc,
siblingDoc, row,
value, siblingDoc,
}) value,
if (typeof result !== 'undefined') { })
row[newKey] = result 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 { } else {
if (toCSVFunctions?.[newKey]) { if (toCSVFunctions?.[newKey]) {
const result = toCSVFunctions[newKey]({ try {
columnName: newKey, const result = toCSVFunctions[newKey]({
data: row, columnName: newKey,
doc, data: row,
row, doc,
siblingDoc, row,
value, siblingDoc,
}) value,
if (typeof result !== 'undefined') { })
row[newKey] = result 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 { } else {
row[newKey] = value row[newKey] = value

View File

@@ -1,21 +1,12 @@
import { import { type FlattenedField, traverseFields, type TraverseFieldsCallback } from 'payload'
type FlattenedField,
type SelectIncludeType,
traverseFields,
type TraverseFieldsCallback,
} from 'payload'
import type { ToCSVFunction } from '../types.js' import type { ToCSVFunction } from '../types.js'
type Args = { type Args = {
fields: FlattenedField[] fields: FlattenedField[]
select: SelectIncludeType | undefined
} }
export const getCustomFieldFunctions = ({ export const getCustomFieldFunctions = ({ fields }: Args): Record<string, ToCSVFunction> => {
fields,
select,
}: Args): Record<string, ToCSVFunction> => {
const result: Record<string, ToCSVFunction> = {} const result: Record<string, ToCSVFunction> = {}
const buildCustomFunctions: TraverseFieldsCallback = ({ field, parentRef, ref }) => { const buildCustomFunctions: TraverseFieldsCallback = ({ field, parentRef, ref }) => {
@@ -54,7 +45,7 @@ export const getCustomFieldFunctions = ({
data[`${ref.prefix}${field.name}_relationTo`] = relationTo data[`${ref.prefix}${field.name}_relationTo`] = relationTo
} }
} }
return undefined return undefined // prevents further flattening
} }
} }
} else { } 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 }) traverseFields({ callback: buildCustomFunctions, fields })

View File

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

View File

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

View File

@@ -569,9 +569,9 @@ describe('@payloadcms/plugin-import-export', () => {
expect(data[0].hasOnePolymorphic_relationTo).toBe('posts') expect(data[0].hasOnePolymorphic_relationTo).toBe('posts')
// hasManyPolymorphic // 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_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') expect(data[0].hasManyPolymorphic_1_relationTo).toBe('posts')
}) })