fix(document-service): discard-draft 500 on self-referential manyToMany relations (#26152)

This commit is contained in:
Ziyi
2026-04-29 11:53:47 +02:00
committed by GitHub
parent 4d3719a327
commit a33f5f5990
4 changed files with 159 additions and 32 deletions
@@ -62,7 +62,7 @@ describe('self-referential-relations', () => {
},
};
const result = await load('api::category.category' as any, [{ id: '10', locale: 'en' }]);
const result = await load('api::category.category' as any, [{ id: 10, locale: 'en' }]);
expect(result).toHaveLength(0);
expect(chain.transacting).not.toHaveBeenCalled();
@@ -86,7 +86,7 @@ describe('self-referential-relations', () => {
},
};
const result = await load('api::category.category' as any, [{ id: '10', locale: 'en' }]);
const result = await load('api::category.category' as any, [{ id: 10, locale: 'en' }]);
expect(result).toHaveLength(0);
});
@@ -102,8 +102,8 @@ describe('self-referential-relations', () => {
},
};
const sourceEntries = [{ id: '10', locale: 'en' }];
const targetEntries = [{ id: '20', locale: 'en' }];
const sourceEntries = [{ id: 10, locale: 'en' }];
const targetEntries = [{ id: 20, locale: 'en' }];
const relationData = [
{
joinTable: {
@@ -111,7 +111,7 @@ describe('self-referential-relations', () => {
joinColumn: { name: 'category_id' },
inverseJoinColumn: { name: 'inv_category_id' },
},
relations: [{ id: 1, category_id: '10', inv_category_id: '10', field_order: 1 }],
relations: [{ id: 1, category_id: 10, inv_category_id: 10, field_order: 1 }],
},
];
@@ -119,7 +119,7 @@ describe('self-referential-relations', () => {
expect(mockBatchInsert).toHaveBeenCalledWith(
'categories_parent_lnk',
[{ category_id: '20', inv_category_id: '20', field_order: 1 }],
[{ category_id: 20, inv_category_id: 20, field_order: 1 }],
1000
);
});
@@ -133,8 +133,8 @@ describe('self-referential-relations', () => {
},
};
const sourceEntries = [{ id: '10', locale: 'en' }];
const targetEntries = [{ id: '20', locale: 'en' }];
const sourceEntries = [{ id: 10, locale: 'en' }];
const targetEntries = [{ id: 20, locale: 'en' }];
const relationData = [
{
joinTable: {
@@ -142,7 +142,7 @@ describe('self-referential-relations', () => {
joinColumn: { name: 'category_id' },
inverseJoinColumn: { name: 'inv_category_id' },
},
relations: [{ id: 1, category_id: '10', inv_category_id: '99', field_order: 1 }],
relations: [{ id: 1, category_id: 10, inv_category_id: 99, field_order: 1 }],
},
];
@@ -161,12 +161,12 @@ describe('self-referential-relations', () => {
};
const sourceEntries = [
{ id: '10', locale: 'en' },
{ id: '11', locale: 'fr' },
{ id: 10, locale: 'en' },
{ id: 11, locale: 'fr' },
];
const targetEntries = [
{ id: '20', locale: 'en' },
{ id: '21', locale: 'fr' },
{ id: 20, locale: 'en' },
{ id: 21, locale: 'fr' },
];
const relationData = [
{
@@ -176,8 +176,8 @@ describe('self-referential-relations', () => {
inverseJoinColumn: { name: 'inv_category_id' },
},
relations: [
{ id: 1, category_id: '10', inv_category_id: '10', field_order: 1 },
{ id: 2, category_id: '11', inv_category_id: '11', field_order: 1 },
{ id: 1, category_id: 10, inv_category_id: 10, field_order: 1 },
{ id: 2, category_id: 11, inv_category_id: 11, field_order: 1 },
],
},
];
@@ -187,8 +187,8 @@ describe('self-referential-relations', () => {
expect(mockBatchInsert).toHaveBeenCalledWith(
'categories_parent_lnk',
[
{ category_id: '20', inv_category_id: '20', field_order: 1 },
{ category_id: '21', inv_category_id: '21', field_order: 1 },
{ category_id: 20, inv_category_id: 20, field_order: 1 },
{ category_id: 21, inv_category_id: 21, field_order: 1 },
],
1000
);
@@ -202,7 +202,7 @@ describe('self-referential-relations', () => {
},
};
await sync([{ id: '10', locale: 'en' }], [{ id: '20', locale: 'en' }], []);
await sync([{ id: 10, locale: 'en' }], [{ id: 20, locale: 'en' }], []);
expect(strapi.db.transaction).not.toHaveBeenCalled();
});
@@ -0,0 +1,124 @@
import { load } from '../unidirectional-relations';
const createChainedQuery = (result: any[]) => {
const chain: any = {};
chain.select = jest.fn().mockReturnValue(chain);
chain.from = jest.fn().mockReturnValue(chain);
chain.whereIn = jest.fn().mockReturnValue(chain);
chain.transacting = jest.fn().mockResolvedValue(result);
return chain;
};
const trxContext = { trx: {} };
const createMockTransaction = () => jest.fn(async (cb: any) => cb(trxContext));
const mockJoinTable = {
name: 'nodes_related_lnk',
joinColumn: { name: 'node_id' },
inverseJoinColumn: { name: 'inv_node_id' },
};
describe('unidirectional-relations', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('load', () => {
it('should skip self-referential attributes (model.uid === uid) to avoid double-processing with selfReferentialRelations', async () => {
const chain = createChainedQuery([{ id: 1, node_id: 10, inv_node_id: 10 }]);
(global as any).strapi = {
contentTypes: {
'api::node.node': { uid: 'api::node.node', options: { draftAndPublish: true } },
},
components: {},
db: {
metadata: {
get: jest.fn().mockReturnValue({
attributes: {
related: {
type: 'relation',
target: 'api::node.node',
joinTable: mockJoinTable,
// no inversedBy or mappedBy — unidirectional self-referential
},
},
}),
},
transaction: createMockTransaction(),
getConnection: jest.fn().mockReturnValue(chain),
},
};
const uid = 'api::node.node' as any;
const oldVersions = [{ id: 10, locale: '' }];
const newVersions = [{ id: 20, locale: '' }];
const result = await load(uid, { oldVersions, newVersions });
// Must return empty — self-referential unidirectional relations are handled by
// selfReferentialRelations, not here, to avoid inserting stale source FK values.
expect(result).toHaveLength(0);
expect(chain.transacting).not.toHaveBeenCalled();
});
it('should capture unidirectional relations from other content types targeting uid', async () => {
// Node's self-referential attribute is skipped before getConnection is called,
// so the only getConnection call will be for the article model's attribute.
const articleChain = createChainedQuery([{ id: 5, article_id: 99, inv_node_id: 10 }]);
(global as any).strapi = {
contentTypes: {
'api::node.node': { uid: 'api::node.node', options: { draftAndPublish: true } },
'api::article.article': {
uid: 'api::article.article',
options: { draftAndPublish: true },
},
},
components: {},
db: {
metadata: {
get: jest.fn().mockImplementation((modelUid: string) => {
if (modelUid === 'api::node.node') {
return {
attributes: {
related: {
type: 'relation',
target: 'api::node.node',
joinTable: mockJoinTable,
},
},
};
}
return {
attributes: {
primaryNode: {
type: 'relation',
target: 'api::node.node',
joinTable: {
name: 'articles_node_lnk',
joinColumn: { name: 'article_id' },
inverseJoinColumn: { name: 'inv_node_id' },
},
},
},
};
}),
},
transaction: createMockTransaction(),
getConnection: jest.fn().mockReturnValue(articleChain),
},
};
const uid = 'api::node.node' as any;
const oldVersions = [{ id: 10, locale: '' }];
const newVersions = [{ id: 20, locale: '' }];
const result = await load(uid, { oldVersions, newVersions });
// Only the article→node relation should be captured (not the self-referential one)
expect(result).toHaveLength(1);
expect(result[0].joinTable.name).toBe('articles_node_lnk');
});
});
});
@@ -1,10 +1,10 @@
/* eslint-disable no-continue */
import { keyBy, omit } from 'lodash/fp';
import type { UID } from '@strapi/types';
import type { Data, UID } from '@strapi/types';
import type { JoinTable } from '@strapi/database';
interface VersionEntry {
id: string;
id: Data.ID;
locale: string;
}
@@ -95,15 +95,15 @@ const sync = async (
const targetEntriesByLocale = keyBy('locale', targetEntries);
// Map source entry IDs → target entry IDs based on locale (string keys for DB/driver consistency)
// Keys stringified for object lookup; values keep the original DB type so PostgreSQL integer columns receive integers, not strings
const idMapping = sourceEntries.reduce(
(acc, sourceEntry) => {
const targetEntry = targetEntriesByLocale[sourceEntry.locale];
if (!targetEntry) return acc;
acc[String(sourceEntry.id)] = String(targetEntry.id);
acc[String(sourceEntry.id)] = targetEntry.id;
return acc;
},
{} as Record<string, string>
{} as Record<string, Data.ID>
);
const batchSize = strapi.db.dialect.getBatchInsertSize();
@@ -1,13 +1,13 @@
/* eslint-disable no-continue */
import { keyBy, omit } from 'lodash/fp';
import type { UID, Schema } from '@strapi/types';
import type { Data, UID, Schema } from '@strapi/types';
import type { JoinTable } from '@strapi/database';
interface LoadContext {
oldVersions: { id: string; locale: string }[];
newVersions: { id: string; locale: string }[];
oldVersions: { id: Data.ID; locale: string }[];
newVersions: { id: Data.ID; locale: string }[];
}
interface RelationUpdate {
@@ -49,13 +49,16 @@ const load = async (
for (const attribute of Object.values(dbModel.attributes) as any) {
/**
* Only consider unidirectional relations
* Only consider unidirectional relations.
* Self-referential relations (model.uid === uid) are handled by selfReferentialRelations;
* processing them here would re-insert stale source FK values pointing to deleted entries.
*/
if (
attribute.type !== 'relation' ||
attribute.target !== uid ||
attribute.inversedBy ||
attribute.mappedBy
attribute.mappedBy ||
model.uid === uid
) {
continue;
}
@@ -155,8 +158,8 @@ const load = async (
* @param oldRelations The relations that were previously loaded with `load` @see load
*/
const sync = async (
oldEntries: { id: string; locale: string }[],
newEntries: { id: string; locale: string }[],
oldEntries: { id: Data.ID; locale: string }[],
newEntries: { id: Data.ID; locale: string }[],
oldRelations: { joinTable: any; relations: any[] }[]
) => {
/**
@@ -169,10 +172,10 @@ const sync = async (
(acc, entry) => {
const newEntry = newEntryByLocale[entry.locale];
if (!newEntry) return acc;
acc[entry.id] = newEntry.id;
acc[String(entry.id)] = newEntry.id;
return acc;
},
{} as Record<string, string>
{} as Record<string, Data.ID>
);
await strapi.db.transaction(async ({ trx }) => {