Skip to content

fix(plugin-import-export): export relationship fields natively for JSON format - #17512

Open
nathanlentz wants to merge 11 commits into
mainfrom
fix/plugin-import-export-json-relationships-main
Open

fix(plugin-import-export): export relationship fields natively for JSON format#17512
nathanlentz wants to merge 11 commits into
mainfrom
fix/plugin-import-export-json-relationships-main

Conversation

@nathanlentz

@nathanlentz nathanlentz commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

Mirrors #17480 (which targets 3.x) onto main.

Problem

hasMany and polymorphic relationship fields were flattened into field_N_id / field_N_relationTo sibling keys for every export format, but only CSV import reverses that flattening. Importing a collection's own JSON export therefore dropped every hasMany and polymorphic relationship.

Fix

The export hooks now check the format and return the relationship value as-is for JSON — an id, an array of ids, or relationTo/value pairs. CSV output is unchanged.

JSON Export before the change:

[
  {
    "id": 2,
    "name": "the wall",
    "description": "the north remembers",
    "events": null,
    "updatedAt": "2026-07-24T18:03:33.856Z",
    "createdAt": "2026-07-24T18:03:33.856Z",
    "events_0_id": 1,
    "events_1_id": 2
  }
]

JSON Export After Change:

[
  {
    "id": 2,
    "name": "the wall",
    "description": "the north remembers",
    "events": [1,2],
    "updatedAt": "2026-07-24T18:03:33.856Z",
    "createdAt": "2026-07-24T18:03:33.856Z",
  }
]

I also updated the Import Preview for JSON imports to render cells in a more formatted manner for polymorphic relationships

image

…ON format

hasMany and polymorphic relationship fields were flattened into
field_N_id / field_N_relationTo sibling keys for every export format, but
only CSV import reverses that flattening. Importing a collection's own JSON
export therefore dropped every hasMany and polymorphic relationship.

The export hooks now check the format and return the relationship value
as-is for JSON — an id, an array of ids, or relationTo/value pairs. CSV
output is unchanged.
Four of the five relationship tests either paraphrased the implementation's
null guards or duplicated the JSON roundtrip coverage already in
test/plugin-import-export/int.spec.ts, and all five went through a local
helper that hand-rolled the beforeExport arg object behind an untyped cast.

Kept the one case nothing else covers: an orphaned entry must leave a gap in
the CSV columns so surviving relationships keep their source index. It now
runs through applyFieldHooks, the real caller, so the arg shape comes from
production code rather than the test.
@github-actions

github-actions Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

📦 esbuild Bundle Analysis for payload

This analysis was generated by esbuild-bundle-analyzer. 🤖

Meta File Out File Size (raw) Note
packages/next/meta_index.json esbuild/index.js 201.89 KB 🆕 Added
packages/payload/meta_index.json esbuild/index.js 1.41 MB 🆕 Added
packages/payload/meta_shared.json esbuild/exports/shared.js 213.16 KB 🆕 Added
packages/richtext-lexical/meta_client.json esbuild/exports/client_optimized/index.js 285.56 KB 🆕 Added
packages/ui/meta_client.json esbuild/exports/client_optimized/index.js 36.44 KB 🆕 Added
packages/ui/meta_shared.json esbuild/exports/shared_optimized/index.js 18.95 KB 🆕 Added
Largest paths These visualization shows top 20 largest paths in the bundle.

Meta file: packages/next/meta_index.json, Out file: esbuild/index.js

Path Size
../../node_modules ${{\color{Goldenrod}{ ████████████████████████▋ }}}$ 98.9%, 197.86 KB
dist/adapters/router.js ${{\color{Goldenrod}{ }}}$ 0.4%, 718 B
dist/adapters/server.js ${{\color{Goldenrod}{ }}}$ 0.3%, 533 B
dist/adapters/layout.js ${{\color{Goldenrod}{ }}}$ 0.3%, 526 B
dist/adapters/views.js ${{\color{Goldenrod}{ }}}$ 0.2%, 409 B
dist/esbuildEntry.js ${{\color{Goldenrod}{ }}}$ 0.0%, 0 B

Meta file: packages/payload/meta_index.json, Out file: esbuild/index.js

Path Size
../../node_modules ${{\color{Goldenrod}{ █████████████████▏ }}}$ 68.7%, 964.38 KB
dist/fields/hooks ${{\color{Goldenrod}{ ▊ }}}$ 3.2%, 44.37 KB
dist/collections/operations ${{\color{Goldenrod}{ ▊ }}}$ 3.0%, 42.75 KB
dist/utilities/configToJSONSchema.js ${{\color{Goldenrod}{ ▎ }}}$ 1.1%, 15.99 KB
dist/auth/operations ${{\color{Goldenrod}{ ▎ }}}$ 1.1%, 15.68 KB
dist/queues/operations ${{\color{Goldenrod}{ ▎ }}}$ 1.0%, 14.29 KB
dist/fields/config ${{\color{Goldenrod}{ ▎ }}}$ 1.0%, 13.74 KB
dist/globals/operations ${{\color{Goldenrod}{ ▎ }}}$ 1.0%, 13.36 KB
dist/fields/validations.js ${{\color{Goldenrod}{ ▏ }}}$ 0.8%, 10.69 KB
dist/bin/generateImportMap ${{\color{Goldenrod}{ ▏ }}}$ 0.7%, 9.84 KB
dist/collections/config ${{\color{Goldenrod}{ ▏ }}}$ 0.7%, 9.30 KB
dist/config/orderable ${{\color{Goldenrod}{ ▏ }}}$ 0.6%, 8.07 KB
dist/index.js ${{\color{Goldenrod}{ ▏ }}}$ 0.6%, 7.96 KB
dist/uploads/fetchAPI-multipart ${{\color{Goldenrod}{ ▏ }}}$ 0.6%, 7.84 KB
dist/hierarchy/utils ${{\color{Goldenrod}{ ▏ }}}$ 0.5%, 7.64 KB
dist/database/migrations ${{\color{Goldenrod}{ ▏ }}}$ 0.5%, 7.55 KB
dist/config/sanitize.js ${{\color{Goldenrod}{ ▏ }}}$ 0.5%, 7.26 KB
dist/collections/endpoints ${{\color{Goldenrod}{ }}}$ 0.4%, 6.12 KB
dist/uploads/endpoints ${{\color{Goldenrod}{ }}}$ 0.4%, 5.56 KB
dist/utilities/telemetry ${{\color{Goldenrod}{ }}}$ 0.4%, 5.43 KB
(other) ${{\color{Goldenrod}{ ███████▊ }}}$ 31.3%, 440.32 KB

Meta file: packages/payload/meta_shared.json, Out file: esbuild/exports/shared.js

Path Size
../../node_modules ${{\color{Goldenrod}{ █████████████████▉ }}}$ 71.9%, 150.13 KB
dist/fields/validations.js ${{\color{Goldenrod}{ █▎ }}}$ 5.1%, 10.69 KB
dist/fields/config ${{\color{Goldenrod}{ ▋ }}}$ 2.8%, 5.82 KB
dist/utilities/traverseFields.js ${{\color{Goldenrod}{ ▌ }}}$ 2.1%, 4.44 KB
dist/collections/config ${{\color{Goldenrod}{ ▍ }}}$ 1.6%, 3.32 KB
dist/config/orderable ${{\color{Goldenrod}{ ▍ }}}$ 1.5%, 3.13 KB
dist/fields/baseFields ${{\color{Goldenrod}{ ▎ }}}$ 1.3%, 2.79 KB
dist/utilities/deepCopyObject.js ${{\color{Goldenrod}{ ▎ }}}$ 1.3%, 2.69 KB
dist/config/client.js ${{\color{Goldenrod}{ ▎ }}}$ 1.3%, 2.68 KB
dist/auth/cookies.js ${{\color{Goldenrod}{ ▏ }}}$ 0.7%, 1.55 KB
dist/utilities/flattenTopLevelFields.js ${{\color{Goldenrod}{ ▏ }}}$ 0.7%, 1.41 KB
dist/utilities/getVersionsConfig.js ${{\color{Goldenrod}{ ▏ }}}$ 0.5%, 1.04 KB
dist/globals/config ${{\color{Goldenrod}{ }}}$ 0.4%, 939 B
dist/utilities/flattenAllFields.js ${{\color{Goldenrod}{ }}}$ 0.4%, 793 B
dist/utilities/unflatten.js ${{\color{Goldenrod}{ }}}$ 0.4%, 779 B
dist/utilities/sanitizeUserDataForEmail.js ${{\color{Goldenrod}{ }}}$ 0.3%, 713 B
dist/auth/extractJWT.js ${{\color{Goldenrod}{ }}}$ 0.3%, 696 B
dist/utilities/getFieldPermissions.js ${{\color{Goldenrod}{ }}}$ 0.3%, 651 B
dist/errors/ValidationError.js ${{\color{Goldenrod}{ }}}$ 0.3%, 577 B
dist/bin/generateImportMap ${{\color{Goldenrod}{ }}}$ 0.3%, 561 B
(other) ${{\color{Goldenrod}{ ███████ }}}$ 28.1%, 58.55 KB

Meta file: packages/richtext-lexical/meta_client.json, Out file: esbuild/exports/client_optimized/index.js

Path Size
dist/features/blocks ${{\color{Goldenrod}{ ███▎ }}}$ 13.2%, 37.20 KB
dist/lexical/ui ${{\color{Goldenrod}{ ███ }}}$ 12.1%, 34.16 KB
dist/lexical/plugins ${{\color{Goldenrod}{ ██▉ }}}$ 11.8%, 33.18 KB
dist/features/experimental_table ${{\color{Goldenrod}{ ██▍ }}}$ 9.6%, 27.22 KB
dist/features/link ${{\color{Goldenrod}{ █▋ }}}$ 6.7%, 18.82 KB
dist/features/toolbars ${{\color{Goldenrod}{ █▍ }}}$ 5.9%, 16.58 KB
dist/features/upload ${{\color{Goldenrod}{ █▎ }}}$ 5.0%, 14.09 KB
dist/features/textState ${{\color{Goldenrod}{ ▉ }}}$ 3.9%, 11.08 KB
dist/lexical/utils ${{\color{Goldenrod}{ ▉ }}}$ 3.5%, 10.02 KB
dist/features/relationship ${{\color{Goldenrod}{ ▊ }}}$ 3.4%, 9.61 KB
dist/features/converters ${{\color{Goldenrod}{ ▊ }}}$ 3.0%, 8.36 KB
dist/utilities/fieldsDrawer ${{\color{Goldenrod}{ ▋ }}}$ 2.9%, 8.12 KB
dist/features/debug ${{\color{Goldenrod}{ ▋ }}}$ 2.6%, 7.40 KB
dist/lexical/config ${{\color{Goldenrod}{ ▍ }}}$ 1.8%, 5.14 KB
dist/features/lists ${{\color{Goldenrod}{ ▎ }}}$ 1.3%, 3.64 KB
dist/features/format ${{\color{Goldenrod}{ ▎ }}}$ 1.2%, 3.28 KB
dist/lexical/LexicalEditor.js ${{\color{Goldenrod}{ ▎ }}}$ 1.1%, 3.23 KB
dist/features/horizontalRule ${{\color{Goldenrod}{ ▎ }}}$ 1.1%, 3.18 KB
dist/field/Field.js ${{\color{Goldenrod}{ ▎ }}}$ 1.0%, 2.88 KB
dist/lexical/nodes ${{\color{Goldenrod}{ ▏ }}}$ 0.9%, 2.66 KB
(other) ${{\color{Goldenrod}{ █████████████████████▋ }}}$ 86.8%, 245.15 KB

Meta file: packages/ui/meta_client.json, Out file: esbuild/exports/client_optimized/index.js

Path Size
dist/exports/client ${{\color{Goldenrod}{ █████████████████████████ }}}$ 100.0%, 26.82 KB

Meta file: packages/ui/meta_shared.json, Out file: esbuild/exports/shared_optimized/index.js

Path Size
dist/graphics/Logo ${{\color{Goldenrod}{ ███████▋ }}}$ 30.5%, 5.57 KB
../../node_modules ${{\color{Goldenrod}{ ███▌ }}}$ 14.5%, 2.65 KB
dist/graphics/Icon ${{\color{Goldenrod}{ ██ }}}$ 8.3%, 1.51 KB
dist/utilities/formatDocTitle ${{\color{Goldenrod}{ █▊ }}}$ 7.2%, 1.32 KB
dist/providers/TableColumns ${{\color{Goldenrod}{ █▏ }}}$ 4.7%, 866 B
dist/utilities/getGlobalData.js ${{\color{Goldenrod}{ █ }}}$ 4.2%, 762 B
dist/utilities/api.js ${{\color{Goldenrod}{ █ }}}$ 4.1%, 756 B
dist/utilities/groupNavItems.js ${{\color{Goldenrod}{ █ }}}$ 4.1%, 745 B
dist/elements/Translation ${{\color{Goldenrod}{ ▋ }}}$ 2.7%, 493 B
dist/utilities/handleTakeOver.js ${{\color{Goldenrod}{ ▌ }}}$ 2.4%, 440 B
dist/utilities/traverseForLocalizedFields.js ${{\color{Goldenrod}{ ▌ }}}$ 2.3%, 419 B
dist/elements/withMergedProps ${{\color{Goldenrod}{ ▍ }}}$ 1.9%, 339 B
dist/utilities/getNavGroups.js ${{\color{Goldenrod}{ ▍ }}}$ 1.9%, 338 B
dist/utilities/getVisibleEntities.js ${{\color{Goldenrod}{ ▍ }}}$ 1.8%, 329 B
dist/elements/WithServerSideProps ${{\color{Goldenrod}{ ▎ }}}$ 1.3%, 232 B
dist/layouts/Root ${{\color{Goldenrod}{ ▎ }}}$ 1.3%, 230 B
dist/utilities/handleGoBack.js ${{\color{Goldenrod}{ ▎ }}}$ 1.0%, 180 B
dist/fields/mergeFieldStyles.js ${{\color{Goldenrod}{ ▏ }}}$ 0.9%, 158 B
dist/forms/Form ${{\color{Goldenrod}{ ▏ }}}$ 0.8%, 152 B
dist/utilities/handleBackToDashboard.js ${{\color{Goldenrod}{ ▏ }}}$ 0.8%, 152 B
(other) ${{\color{Goldenrod}{ █████████████████▍ }}}$ 69.5%, 12.68 KB
Details

Next to the size is how much the size has increased or decreased compared with the base branch of this PR.

  • ‼️: Size increased by 20% or more. Special attention should be given to this.
  • ⚠️: Size increased in acceptable range (lower than 20%).
  • ✅: No change or even downsized.
  • 🗑️: The out file is deleted: not found in base branch.
  • 🆕: The out file is newly found: will be added to base branch.

@paulpopus paulpopus left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In addition to the comments below:

I want you to double check in the UI and if there are any changes to the preview table and if there are, then write e2e tests covering for regressions in this area

Comment thread test/plugin-import-export/int.spec.ts Outdated
Comment thread test/plugin-import-export/int.spec.ts Outdated
Comment thread test/plugin-import-export/payload-types.ts
@nathanlentz

Copy link
Copy Markdown
Collaborator Author

In addition to the comments below:

I want you to double check in the UI and if there are any changes to the preview table and if there are, then write e2e tests covering for regressions in this area

Yes, the preview table changed. I created a new way to present those relationships for JSON imports and added e2e tests.

@nathanlentz
nathanlentz requested a review from paulpopus July 28, 2026 20:13
nathanlentz added a commit that referenced this pull request Jul 28, 2026
…llection

Ports the review feedback from #17512: renders relationship and upload
preview cells through a dedicated RelationshipCell grouped by target
collection, drops dangling references from exports rather than exporting
nulls the import would reject, and adds unit, integration, and e2e
coverage.
Comment on lines +61 to +84
const result = applyFieldHooks({
type: 'beforeExport',
// Exports populate at depth 1, so `value: null` is an orphaned reference —
// the target doc was deleted out from under it.
data: {
rel: [
{ relationTo: 'users', value: null },
{ relationTo: 'posts', value: 'p1' },
],
},
fieldHooks: getExportFieldFunctions({ fields }),
fields,
format: 'csv',
operation: 'export',
req: mockReq,
})

// The surviving entry stays at index 1 — shifting it to 0 would silently
// rewrite column names for every consumer of the CSV.
expect(result).toEqual({
rel: null,
rel_1_id: 'p1',
rel_1_relationTo: 'posts',
})

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test doesn't exercise the CSV pipeline it's named for.

This drives the hasMany-polymorphic handler through applyFieldHooks with format: 'csv', but real CSV export never calls applyFieldHooks — it exclusively uses flattenObject, and the two disagree on what a hook's null return means for an array field:

  • applyFieldHooks.ts: if (typeof transformed !== 'undefined') { result[field.name] = transformed } → writes rel: null.
  • flattenObject.ts (array branch): if (result === null) { return } → exits without ever writing row[fieldPath], so rel is absent from the row entirely.

The asserted shape { rel: null, rel_1_id: 'p1', rel_1_relationTo: 'posts' } isn't what a real generated CSV row looks like — it would have no rel key at all. The column-pinning behavior (index 1 stays index 1) is validated correctly, but the rel: null half of the assertion wouldn't catch a real regression in flattenObject.

Suggest calling flattenObject directly here (matching what createExport.ts/export/handlePreview.ts actually do for CSV) and dropping/correcting the rel: null expectation.

Comment on lines +408 to +444
describe('hasMany with gaps in the column indices', () => {
// Export pins each column to its source index, so an entry that could not be
// resolved to an id leaves a gap. Import must absorb the gap rather than
// emitting a null array entry.
const hasManyFields: FlattenedField[] = [
{
name: 'rels',
type: 'relationship',
hasMany: true,
relationTo: ['posts', 'pages'],
} as FlattenedField,
]

const expected = {
rels: [{ relationTo: 'posts', value: 'p1' }],
}

it('should absorb a leading gap left by an unresolvable entry', () => {
const data = {
rels_1_id: 'p1',
rels_1_relationTo: 'posts',
}

expect(unflattenObject({ data, fields: hasManyFields, req: mockReq })).toEqual(expected)
})

it('should absorb a gap padded with empty strings by schema columns', () => {
const data = {
rels_0_id: '',
rels_0_relationTo: '',
rels_1_id: 'p1',
rels_1_relationTo: 'posts',
}

expect(unflattenObject({ data, fields: hasManyFields, req: mockReq })).toEqual(expected)
})
})

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This block covers only a leading gap (index 0 missing, or index 0 padded with empty strings; index 1 present). There's no case for a gap strictly between two present entries (index 0 present, index 1 missing, index 2 present). The current behavior is correct, but there's no test to ensure no regression in the future.

Comment on lines +117 to 139
registerHandler(({ format, siblingData, value }) => {
if (!Array.isArray(value)) {
return undefined
}
return undefined
// `index` is carried so CSV columns stay pinned to the source position: an entry
// that cannot be resolved to an id leaves a gap rather than shifting its siblings.
const rels = value.flatMap((val, index) => {
if (!isPolymorphicRelValue(val)) {
return []
}
const id = getPolymorphicRelId(val)
return id === undefined ? [] : [{ id, index, relationTo: val.relationTo }]
})

if (format === 'json') {
return rels.map((rel) => ({ relationTo: rel.relationTo, value: rel.id }))
}
rels.forEach(({ id, index, relationTo }) => {
siblingData[`${fullKey}_${index}_id`] = id
siblingData[`${fullKey}_${index}_relationTo`] = relationTo
})
return null
})

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No test (unit or integration) exercises the hasMany-polymorphic + JSON + dangling-reference combination for this handler. Suggest adding a unit test similar to the existing CSV "pin columns" test above but with format: 'json', asserting the dangling entry is dropped and the surviving entry's {relationTo, value} shape is correct.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added. Same fields/data as the CSV pin-columns test, with format: 'json'. One comment though. it
asserts [null, {relationTo, value}] rather than the dangling entry being dropped, following your
note on line 113 about preserving nulls so JSON matches CSV.

Comment on lines 96 to 113
if (!Array.isArray(field.relationTo)) {
registerHandler(({ siblingData, value }) => {
if (Array.isArray(value)) {
value.forEach((val, i) => {
const id = typeof val === 'object' && val ? (val as { id: unknown }).id : val
siblingData[`${fullKey}_${i}_id`] = id
})
return null
registerHandler(({ format, siblingData, value }) => {
if (!Array.isArray(value)) {
return undefined
}
return undefined
const ids = value.map((val) =>
typeof val === 'object' && val ? (val as { id: unknown }).id : val,
)
// A dangling reference cannot be imported back, so it is dropped rather than
// exported as a null the import would reject as an invalid relationship.
if (format === 'json') {
return ids.filter((id) => id !== undefined && id !== null)
}
ids.forEach((id, i) => {
siblingData[`${fullKey}_${i}_id`] = id
})
return null
})

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

JSON export will now change array length/position for dangling entries; CSV deliberately doesn't, and this asymmetry isn't documented.

A doc with rel: ['p1', 'p2'] where p1 is soft-deleted exports as CSV with both positions preserved (one as a gap), but as JSON rel: ['p2'] — length 1, not 2. Any other array field on the doc that a consumer expects to stay index-aligned with rel silently loses that alignment for JSON only.

I would lean on allowing exports to have null for now so that JSON matches with CSVs in behaviour. Since that is the underlying data shape. In the future we can do a "skip null" type of flag to handle these edge cases.

… exports

Keep a dangling hasMany reference as null at its source index so JSON
exports preserve the same length and positions as CSV, which pins each
entry to its own column.

Test coverage from review:
- drive the hasMany polymorphic CSV column test through flattenObject,
  which is what real CSV export uses instead of applyFieldHooks
- cover a gap strictly between two present column indices on import
- cover hasMany polymorphic + JSON + dangling reference
@nathanlentz
nathanlentz requested a review from paulpopus July 29, 2026 16:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants