mirror of
https://github.com/strapi/strapi.git
synced 2026-05-03 16:22:30 +00:00
fix(document-service): discard-draft 500 on self-referential manyToMany relations (#26152)
This commit is contained in:
+18
-18
@@ -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();
|
||||
});
|
||||
|
||||
+124
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
+5
-5
@@ -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 }) => {
|
||||
|
||||
Reference in New Issue
Block a user