diff --git a/packages/plugin-import-export/src/utilities/getExportFieldFunctions.ts b/packages/plugin-import-export/src/utilities/getExportFieldFunctions.ts index 4d10209621e..aaaedb84ddc 100644 --- a/packages/plugin-import-export/src/utilities/getExportFieldFunctions.ts +++ b/packages/plugin-import-export/src/utilities/getExportFieldFunctions.ts @@ -75,46 +75,62 @@ const registerExportHandler = ( return } - registerHandler(({ siblingData, value }) => { - if (isPolymorphicRelValue(value)) { - const id = getPolymorphicRelId(value) - if (id !== undefined) { - siblingData[`${fullKey}_id`] = id - siblingData[`${fullKey}_relationTo`] = value.relationTo - } + registerHandler(({ format, siblingData, value }) => { + if (!isPolymorphicRelValue(value)) { + return format === 'json' ? undefined : null } + const id = getPolymorphicRelId(value) + if (id === undefined) { + return format === 'json' ? undefined : null + } + + // JSON can hold the relationship natively — no columns to flatten into. + if (format === 'json') { + return { relationTo: value.relationTo, value: id } + } + siblingData[`${fullKey}_id`] = id + siblingData[`${fullKey}_relationTo`] = value.relationTo return null }) return } 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, + ) + // JSON can hold the relationship natively — no columns to flatten into. + if (format === 'json') { + return ids + } + ids.forEach((id, i) => { + siblingData[`${fullKey}_${i}_id`] = id + }) + return null }) return } - registerHandler(({ siblingData, value }) => { - if (Array.isArray(value)) { - value.forEach((val, i) => { - if (isPolymorphicRelValue(val)) { - const id = getPolymorphicRelId(val) - if (id !== undefined) { - siblingData[`${fullKey}_${i}_id`] = id - siblingData[`${fullKey}_${i}_relationTo`] = val.relationTo - } - } - }) - return null + registerHandler(({ format, siblingData, value }) => { + if (!Array.isArray(value)) { + return undefined + } + const rels = value + .filter((val) => isPolymorphicRelValue(val)) + .map((val) => ({ id: getPolymorphicRelId(val), relationTo: val.relationTo })) + .filter((rel) => rel.id !== undefined) + // JSON can hold the relationship natively — no columns to flatten into. + if (format === 'json') { + return rels.map((rel) => ({ relationTo: rel.relationTo, value: rel.id })) } - return undefined + rels.forEach((rel, i) => { + siblingData[`${fullKey}_${i}_id`] = rel.id + siblingData[`${fullKey}_${i}_relationTo`] = rel.relationTo + }) + return null }) } diff --git a/test/plugin-import-export/int.spec.ts b/test/plugin-import-export/int.spec.ts index 836939c5cfa..ff859e60b8c 100644 --- a/test/plugin-import-export/int.spec.ts +++ b/test/plugin-import-export/int.spec.ts @@ -4677,6 +4677,391 @@ describe('@payloadcms/plugin-import-export', () => { }) }) + it('should roundtrip a hasMany monomorphic relationship through CSV export/import', async () => { + const post1 = await payload.create({ + collection: 'posts', + data: { + title: 'hasMany Monomorphic Post 1', + }, + }) + const post2 = await payload.create({ + collection: 'posts', + data: { + title: 'hasMany Monomorphic Post 2', + }, + }) + + const testPage = await payload.create({ + collection: 'pages', + data: { + title: 'hasMany Monomorphic Roundtrip', + hasManyMonomorphic: [post1.id, post2.id], + _status: 'published', + }, + }) + + const exportDoc = await payload.create({ + collection: 'exports', + user, + data: { + collectionSlug: 'pages', + fields: ['id', 'title', 'hasManyMonomorphic'], + format: 'csv', + where: { + id: { equals: testPage.id }, + }, + }, + }) + + await payload.jobs.run() + + const exportedDoc = await payload.findByID({ + collection: 'exports', + id: exportDoc.id, + }) + + const csvPath = path.join(dirname, './uploads', exportedDoc.filename as string) + + await payload.delete({ + collection: 'pages', + id: testPage.id, + }) + + let importDoc = await payload.create({ + collection: 'imports', + user, + data: { + collectionSlug: 'pages', + importMode: 'create', + }, + file: { + data: fs.readFileSync(csvPath), + mimetype: 'text/csv', + name: 'hasmany-monomorphic-roundtrip.csv', + size: fs.statSync(csvPath).size, + }, + }) + + await payload.jobs.run() + + importDoc = await payload.findByID({ + collection: 'imports', + id: importDoc.id, + }) + + expect(importDoc.status).toBe('completed') + expect(importDoc.summary?.imported).toBe(1) + expect(importDoc.summary?.issues).toBe(0) + + const importedPages = await payload.find({ + collection: 'pages', + where: { + title: { equals: 'hasMany Monomorphic Roundtrip' }, + }, + depth: 0, + }) + + expect(importedPages.docs).toHaveLength(1) + const imported = importedPages.docs[0] + + expect(imported?.hasManyMonomorphic).toHaveLength(2) + expect((imported?.hasManyMonomorphic ?? []).map(extractID)).toEqual([post1.id, post2.id]) + + await payload.delete({ + collection: 'pages', + where: { + title: { equals: 'hasMany Monomorphic Roundtrip' }, + }, + }) + await payload.delete({ collection: 'posts', id: post1.id }) + await payload.delete({ collection: 'posts', id: post2.id }) + }) + + it('hasMany relationship is empty after JSON export/import roundtrip', async () => { + const post1 = await payload.create({ + collection: 'posts', + data: { title: 'hasMany JSON Post 1' }, + }) + const post2 = await payload.create({ + collection: 'posts', + data: { title: 'hasMany JSON Post 2' }, + }) + + const testPage = await payload.create({ + collection: 'pages', + data: { + title: 'hasMany JSON Roundtrip', + hasManyMonomorphic: [post1.id, post2.id], + _status: 'published', + }, + }) + + const exportDoc = await payload.create({ + collection: 'exports', + user, + data: { + collectionSlug: 'pages', + fields: ['id', 'title', 'hasManyMonomorphic'], + format: 'json', + where: { + id: { equals: testPage.id }, + }, + }, + }) + + await payload.jobs.run() + + const exportedDoc = await payload.findByID({ + collection: 'exports', + id: exportDoc.id, + }) + + const jsonPath = path.join(dirname, './uploads', exportedDoc.filename as string) + + await payload.delete({ + collection: 'pages', + id: testPage.id, + }) + + let importDoc = await payload.create({ + collection: 'imports', + user, + data: { + collectionSlug: 'pages', + importMode: 'create', + }, + file: { + data: fs.readFileSync(jsonPath), + mimetype: 'application/json', + name: 'hasmany-json-roundtrip.json', + size: fs.statSync(jsonPath).size, + }, + }) + + await payload.jobs.run() + + importDoc = await payload.findByID({ + collection: 'imports', + id: importDoc.id, + }) + + const importedPages = await payload.find({ + collection: 'pages', + where: { + title: { equals: 'hasMany JSON Roundtrip' }, + }, + depth: 0, + }) + const imported = importedPages.docs[0] + + await payload.delete({ + collection: 'pages', + where: { + title: { equals: 'hasMany JSON Roundtrip' }, + }, + }) + await payload.delete({ collection: 'posts', id: post1.id }) + await payload.delete({ collection: 'posts', id: post2.id }) + + expect(importDoc.status).toBe('completed') + expect(importedPages.docs).toHaveLength(1) + expect(imported?.hasManyMonomorphic).toHaveLength(2) + expect((imported?.hasManyMonomorphic ?? []).map(extractID)).toEqual([post1.id, post2.id]) + }) + + it('hasOnePolymorphic: relationship is empty after JSON export/import roundtrip', async () => { + const post1 = await payload.create({ + collection: 'posts', + data: { title: 'hasOnePolymorphic JSON Post' }, + }) + + const testPage = await payload.create({ + collection: 'pages', + data: { + title: 'hasOnePolymorphic JSON Roundtrip', + hasOnePolymorphic: { + relationTo: 'posts', + value: post1.id, + }, + _status: 'published', + }, + }) + + const exportDoc = await payload.create({ + collection: 'exports', + user, + data: { + collectionSlug: 'pages', + fields: ['id', 'title', 'hasOnePolymorphic'], + format: 'json', + where: { + id: { equals: testPage.id }, + }, + }, + }) + + await payload.jobs.run() + + const exportedDoc = await payload.findByID({ + collection: 'exports', + id: exportDoc.id, + }) + + const jsonPath = path.join(dirname, './uploads', exportedDoc.filename as string) + + await payload.delete({ + collection: 'pages', + id: testPage.id, + }) + + let importDoc = await payload.create({ + collection: 'imports', + user, + data: { + collectionSlug: 'pages', + importMode: 'create', + }, + file: { + data: fs.readFileSync(jsonPath), + mimetype: 'application/json', + name: 'hasone-polymorphic-json-roundtrip.json', + size: fs.statSync(jsonPath).size, + }, + }) + + await payload.jobs.run() + + importDoc = await payload.findByID({ + collection: 'imports', + id: importDoc.id, + }) + + const importedPages = await payload.find({ + collection: 'pages', + where: { + title: { equals: 'hasOnePolymorphic JSON Roundtrip' }, + }, + depth: 0, + }) + const imported = importedPages.docs[0] + + await payload.delete({ + collection: 'pages', + where: { + title: { equals: 'hasOnePolymorphic JSON Roundtrip' }, + }, + }) + await payload.delete({ collection: 'posts', id: post1.id }) + + expect(importDoc.status).toBe('completed') + expect(importedPages.docs).toHaveLength(1) + expect(imported?.hasOnePolymorphic).toEqual({ + relationTo: 'posts', + value: post1.id, + }) + }) + + it('hasManyPolymorphic: relationship is empty after JSON export/import roundtrip', async () => { + const testUser = await payload.find({ + collection: 'users', + limit: 1, + }) + const post1 = await payload.create({ + collection: 'posts', + data: { title: 'hasManyPolymorphic JSON Post' }, + }) + + const testPage = await payload.create({ + collection: 'pages', + data: { + title: 'hasManyPolymorphic JSON Roundtrip', + hasManyPolymorphic: [ + { relationTo: 'users', value: testUser.docs[0]?.id }, + { relationTo: 'posts', value: post1.id }, + ], + _status: 'published', + }, + }) + + const exportDoc = await payload.create({ + collection: 'exports', + user, + data: { + collectionSlug: 'pages', + fields: ['id', 'title', 'hasManyPolymorphic'], + format: 'json', + where: { + id: { equals: testPage.id }, + }, + }, + }) + + await payload.jobs.run() + + const exportedDoc = await payload.findByID({ + collection: 'exports', + id: exportDoc.id, + }) + + const jsonPath = path.join(dirname, './uploads', exportedDoc.filename as string) + + await payload.delete({ + collection: 'pages', + id: testPage.id, + }) + + let importDoc = await payload.create({ + collection: 'imports', + user, + data: { + collectionSlug: 'pages', + importMode: 'create', + }, + file: { + data: fs.readFileSync(jsonPath), + mimetype: 'application/json', + name: 'hasmany-polymorphic-json-roundtrip.json', + size: fs.statSync(jsonPath).size, + }, + }) + + await payload.jobs.run() + + importDoc = await payload.findByID({ + collection: 'imports', + id: importDoc.id, + }) + + const importedPages = await payload.find({ + collection: 'pages', + where: { + title: { equals: 'hasManyPolymorphic JSON Roundtrip' }, + }, + depth: 0, + }) + const imported = importedPages.docs[0] + + await payload.delete({ + collection: 'pages', + where: { + title: { equals: 'hasManyPolymorphic JSON Roundtrip' }, + }, + }) + await payload.delete({ collection: 'posts', id: post1.id }) + + expect(importDoc.status).toBe('completed') + expect(importedPages.docs).toHaveLength(1) + expect(imported?.hasManyPolymorphic).toHaveLength(2) + expect(imported?.hasManyPolymorphic?.[0]).toEqual({ + relationTo: 'users', + value: testUser.docs[0]?.id, + }) + expect(imported?.hasManyPolymorphic?.[1]).toEqual({ + relationTo: 'posts', + value: post1.id, + }) + }) + describe('batch processing', () => { it('should process large imports in batches', async () => { const rows = ['title,excerpt']