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