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:
@@ -106,7 +106,6 @@ export const createExport = async (args: CreateExportArgs) => {
|
||||
|
||||
const toCSVFunctions = getCustomFieldFunctions({
|
||||
fields: collectionConfig.flattenedFields,
|
||||
select,
|
||||
})
|
||||
|
||||
if (download) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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[])
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user