Skip to content

Commit cf9252d

Browse files
authored
feat(plugin-import-export): out of beta and added support for collection-level and field-level hooks (#16556)
### Plugin out of beta The import export plugin is now marked as stable and will be moved out of beta. For v4 we will be removing the deprecated APIs but for v3 they will remain supported. ### Why Field-level hooks are essential because: 1. **Co-location** — the transform behavior lives next to the field definition, not in a separate collection config 2. **Reusability** — a shared field config carries its hooks to every collection that uses it, zero duplication 3. **Deeply nested fields** — transforming `group.tabs.namedTab.array[0].field` in a collection-level hook requires manually navigating the full document structure; field-level hooks handle this transparently This PR keeps both levels: field-level hooks for per-field transforms, collection-level hooks for batch-wide operations (masking, logging, filtering). ## Field-level hooks Defined per-field via `custom['plugin-import-export'].hooks`: ```ts import type { FieldBeforeExportHook } from '@payloadcms/plugin-import-export' const authorField = { name: 'author', type: 'relationship', relationTo: 'users', custom: { 'plugin-import-export': { hooks: { // Runs before the field value is written to the export file beforeExport: (({ value, columnName, row, format }) => { if (format === 'csv' && value && typeof value === 'object' && 'id' in value) { row[`${columnName}_id`] = value.id row[`${columnName}_email`] = value.email return undefined // let row mutation take effect } return value }) satisfies FieldBeforeExportHook, // Runs before the field value is written to the database beforeImport: ({ value, columnName, data, format }) => { if (format === 'csv') { return data[`${columnName}_id`] ?? undefined } return value }, }, }, }, } ``` ## Collection-level hooks Defined in the plugin config: ```ts importExportPlugin({ collections: [ { slug: 'users', export: { hooks: { before: ({ data, format }) => { // Mask sensitive fields from the entire batch return data.map(({ passwordHash, ssn, ...safe }) => safe) }, after: ({ batchNumber, totalBatches, req }) => { req.payload.logger.info(`Export batch ${batchNumber}/${totalBatches} written`) }, }, }, }, ], }) ``` #### Execution order field-level `hooks.beforeExport` / `hooks.beforeImport` run first (per-field, per-document), then collection-level `hooks.before` / `hooks.after` run on the already-transformed batch. #### Migration from toCSV / fromCSV toCSV and fromCSV remain fully functional but are deprecated — removed in v4.0. Migration is a 1:1 rename plus the new format parameter: ```ts // Before (deprecated) custom: { 'plugin-import-export': { toCSV: ({ value, row }) => { ... }, fromCSV: ({ value, data }) => { ... }, }, } // After custom: { 'plugin-import-export': { hooks: { beforeExport: ({ value, row, format }) => { ... }, beforeImport: ({ value, data, format }) => { ... }, }, }, } ``` ## What changed ### New types `FieldBeforeExportHook` — field-level export hook type (same args as ToCSVFunction + format) `FieldBeforeImportHook` — field-level import hook type (same args as FromCSVFunction + format) `ToCSVFunction` / `FromCSVFunction` — now deprecated aliases #### JSON format support (new) - Field-level hooks now fire for JSON exports/imports, not just CSV - New applyFieldExportHooks / applyFieldImportHooks utilities traverse nested JSON documents and apply field hooks at each position - Preview handlers also apply field hooks for both CSV and JSON ## Bug fixes - [x] Fixed parentPath key computation in getExportFieldFunctions / getImportFieldFunctions — named tabs inside groups now produce correct underscore-separated keys (pre-existing bug) - [x] Fixed slice(-0) bug in import batchProcessor.ts — ImportAfterHook was receiving all accumulated errors instead of per-batch errors when a batch had zero failures - [x] Removed unused fs import from hooks.int.spec.ts ## Renamed internals - toCSVFunctions → exportFieldHooks throughout all source files - fromCSVFunctions → importFieldHooks throughout all source files - Error messages updated from "toCSVFunction" to "field export hook" ## Checklist - [x] Documentation updated with new API, migration guide, execution order - [x] Test collection PostsWithFieldHooks with field-level hooks - [x] 12 new integration tests (CSV export, JSON export, format parameter, deeply nested fields, CSV import, JSON import, backward compatibility with toCSV, backward compatibility with fromCSV, execution order, reusable field config, priority, default behaviour)
1 parent d17298f commit cf9252d

47 files changed

Lines changed: 6530 additions & 843 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/plugins/import-export.mdx

Lines changed: 339 additions & 78 deletions
Large diffs are not rendered by default.

packages/plugin-import-export/src/export/batchProcessor.ts

Lines changed: 102 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
*/
55
import type { PayloadRequest, SelectType, Sort, TypedUser, Where } from 'payload'
66

7+
import type { ExportAfterHook, ExportBeforeHook } from '../types.js'
8+
79
import { type BatchProcessorOptions } from '../utilities/useBatchProcessor.js'
810

911
/**
@@ -46,6 +48,11 @@ export interface ExportProcessOptions<TDoc = unknown> {
4648
* The export format - affects column tracking for CSV
4749
*/
4850
format: 'csv' | 'json'
51+
/** Lifecycle hooks for this export operation */
52+
hooks?: {
53+
after?: ExportAfterHook
54+
before?: ExportBeforeHook
55+
}
4956
/**
5057
* Maximum number of documents to export
5158
*/
@@ -58,6 +65,8 @@ export interface ExportProcessOptions<TDoc = unknown> {
5865
* Starting page for pagination (default: 1)
5966
*/
6067
startPage?: number
68+
/** Total number of docs available (used to compute totalBatches for hooks) */
69+
totalDocs?: number
6170
/**
6271
* Transform function to apply to each document
6372
*/
@@ -98,14 +107,22 @@ export interface ExportResult {
98107
* format: 'csv',
99108
* maxDocs: 1000,
100109
* req,
101-
* transformDoc: (doc) => flattenObject({ doc }),
110+
* transformDoc: (doc) => flattenObject({ data: doc }),
102111
* })
103112
* ```
104113
*/
105114
export function createExportBatchProcessor(options: ExportBatchProcessorOptions = {}) {
106115
const batchSize = options.batchSize ?? 100
107116
const debug = options.debug ?? false
108117

118+
const computeTotalBatches = (totalDocs: number | undefined, maxDocs: number): number => {
119+
const effectiveDocs =
120+
totalDocs !== undefined
121+
? Math.min(totalDocs, maxDocs === Number.POSITIVE_INFINITY ? totalDocs : maxDocs)
122+
: 0
123+
return effectiveDocs > 0 ? Math.ceil(effectiveDocs / batchSize) : 1
124+
}
125+
109126
/**
110127
* Process an export operation by fetching and transforming documents in batches.
111128
*
@@ -115,7 +132,18 @@ export function createExportBatchProcessor(options: ExportBatchProcessorOptions
115132
const processExport = async <TDoc>(
116133
processOptions: ExportProcessOptions<TDoc>,
117134
): Promise<ExportResult> => {
118-
const { findArgs, format, maxDocs, req, startPage = 1, transformDoc } = processOptions
135+
const {
136+
findArgs,
137+
format,
138+
hooks,
139+
maxDocs,
140+
req,
141+
startPage = 1,
142+
totalDocs,
143+
transformDoc,
144+
} = processOptions
145+
146+
const totalBatches = computeTotalBatches(totalDocs, maxDocs)
119147

120148
const docs: Record<string, unknown>[] = []
121149
const columnsSet = new Set<string>()
@@ -144,13 +172,27 @@ export function createExportBatchProcessor(options: ExportBatchProcessorOptions
144172
)
145173
}
146174

147-
for (const doc of result.docs) {
148-
const transformedDoc = transformDoc(doc as TDoc)
149-
docs.push(transformedDoc)
175+
const batchNumber = currentPage - startPage + 1
176+
const originalDocs = result.docs as Record<string, unknown>[]
177+
const batchData = result.docs.map((doc) => transformDoc(doc as TDoc))
178+
179+
const dataToWrite =
180+
hooks?.before && batchData.length > 0
181+
? await hooks.before({
182+
batchNumber,
183+
data: batchData,
184+
format,
185+
originalData: originalDocs,
186+
req,
187+
totalBatches,
188+
})
189+
: batchData
190+
191+
for (const row of dataToWrite) {
192+
docs.push(row)
150193

151-
// Track columns for CSV format
152194
if (format === 'csv') {
153-
for (const key of Object.keys(transformedDoc)) {
195+
for (const key of Object.keys(row)) {
154196
if (!columnsSet.has(key)) {
155197
columnsSet.add(key)
156198
columns.push(key)
@@ -159,6 +201,17 @@ export function createExportBatchProcessor(options: ExportBatchProcessorOptions
159201
}
160202
}
161203

204+
if (hooks?.after && dataToWrite.length > 0) {
205+
await hooks.after({
206+
batchNumber,
207+
data: dataToWrite,
208+
format,
209+
originalData: originalDocs,
210+
req,
211+
totalBatches,
212+
})
213+
}
214+
162215
fetched += result.docs.length
163216
hasNextPage = result.hasNextPage && fetched < maxDocs
164217
currentPage++
@@ -187,7 +240,18 @@ export function createExportBatchProcessor(options: ExportBatchProcessorOptions
187240
async function* streamExport<TDoc>(
188241
processOptions: ExportProcessOptions<TDoc>,
189242
): AsyncGenerator<{ columns: string[]; docs: Record<string, unknown>[] }> {
190-
const { findArgs, format, maxDocs, req, startPage = 1, transformDoc } = processOptions
243+
const {
244+
findArgs,
245+
format,
246+
hooks,
247+
maxDocs,
248+
req,
249+
startPage = 1,
250+
totalDocs,
251+
transformDoc,
252+
} = processOptions
253+
254+
const totalBatches = computeTotalBatches(totalDocs, maxDocs)
191255

192256
const columnsSet = new Set<string>()
193257
const columns: string[] = []
@@ -215,15 +279,25 @@ export function createExportBatchProcessor(options: ExportBatchProcessorOptions
215279
)
216280
}
217281

218-
const batchDocs: Record<string, unknown>[] = []
219-
220-
for (const doc of result.docs) {
221-
const transformedDoc = transformDoc(doc as TDoc)
222-
batchDocs.push(transformedDoc)
223-
224-
// Track columns for CSV format
282+
const batchNumber = currentPage - startPage + 1
283+
const originalDocs = result.docs as Record<string, unknown>[]
284+
const batchData = result.docs.map((doc) => transformDoc(doc as TDoc))
285+
286+
const dataToWrite =
287+
hooks?.before && batchData.length > 0
288+
? await hooks.before({
289+
batchNumber,
290+
data: batchData,
291+
format,
292+
originalData: originalDocs,
293+
req,
294+
totalBatches,
295+
})
296+
: batchData
297+
298+
for (const row of dataToWrite) {
225299
if (format === 'csv') {
226-
for (const key of Object.keys(transformedDoc)) {
300+
for (const key of Object.keys(row)) {
227301
if (!columnsSet.has(key)) {
228302
columnsSet.add(key)
229303
columns.push(key)
@@ -232,7 +306,18 @@ export function createExportBatchProcessor(options: ExportBatchProcessorOptions
232306
}
233307
}
234308

235-
yield { columns: [...columns], docs: batchDocs }
309+
yield { columns: [...columns], docs: dataToWrite }
310+
311+
if (hooks?.after && dataToWrite.length > 0) {
312+
await hooks.after({
313+
batchNumber,
314+
data: dataToWrite,
315+
format,
316+
originalData: originalDocs,
317+
req,
318+
totalBatches,
319+
})
320+
}
236321

237322
fetched += result.docs.length
238323
hasNextPage = result.hasNextPage && fetched < maxDocs

0 commit comments

Comments
 (0)