fix: syncing components in draft and publish relations (#24227)

Co-authored-by: Mark Kaylor <mark.kaylor@strapi.io>
Co-authored-by: Ben Irvin <ben.irvin@strapi.io>
Co-authored-by: Jamie Howard <48524071+jhoward1994@users.noreply.github.com>
This commit is contained in:
Bassel Kanso 2025-09-03 17:39:31 +03:00 committed by GitHub
parent 80e8a7c212
commit a1da9b829e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 346 additions and 18 deletions

View File

@ -2,6 +2,12 @@ import _ from 'lodash';
import { has, omit, pipe, assign, curry } from 'lodash/fp';
import type { Utils, UID, Schema, Data, Modules } from '@strapi/types';
import { contentTypes as contentTypesUtils, async, errors } from '@strapi/utils';
import {
getComponentJoinTableName,
getComponentJoinColumnEntityName,
getComponentJoinColumnInverseName,
getComponentTypeColumn,
} from '../../utils/transform-content-types-to-models';
// type aliases for readability
type Input<T extends UID.Schema> = Modules.Documents.Params.Data.Input<T>;
@ -431,6 +437,158 @@ const assignComponentData = curry(
}
);
/** *************************
Component relation handling for document operations
************************** */
/**
* Find the parent entry of a component instance.
*
* Given a component model, a specific component instance id, and the list of
* possible parent content types (those that can embed this component),
* this function checks each parent's *_cmps join table to see if the component
* instance is linked to a parent entity.
*
* - Returns the parent uid, parent table name, and parent id if found.
* - Returns null if no parent relationship exists.
*/
const findComponentParent = async (
componentSchema: Schema.Component,
componentId: number | string,
parentSchemasForComponent: Schema.ContentType[],
opts?: { trx?: any }
): Promise<{ uid: string; table: string; parentId: number | string } | null> => {
if (!componentSchema?.uid) return null;
const schemaBuilder = strapi.db.getSchemaConnection(opts?.trx);
const withTrx = (qb: any) => (opts?.trx ? qb.transacting(opts.trx) : qb);
for (const parent of parentSchemasForComponent) {
if (!parent.collectionName) continue;
// Use the exact same functions that create the tables
const identifiers = strapi.db.metadata.identifiers;
const joinTableName = getComponentJoinTableName(parent.collectionName, identifiers);
try {
const tableExists = await schemaBuilder.hasTable(joinTableName);
if (!tableExists) continue;
// Use the exact same functions that create the columns
const entityIdColumn = getComponentJoinColumnEntityName(identifiers);
const componentIdColumn = getComponentJoinColumnInverseName(identifiers);
const componentTypeColumn = getComponentTypeColumn(identifiers);
const parentRow = await withTrx(strapi.db.getConnection(joinTableName))
.where({
[componentIdColumn]: componentId,
[componentTypeColumn]: componentSchema.uid,
})
.first(entityIdColumn);
if (parentRow) {
return {
uid: parent.uid,
table: parent.collectionName,
parentId: parentRow[entityIdColumn],
};
}
} catch {
continue;
}
}
return null;
};
/**
* Finds content types that contain the given component and have draft & publish enabled.
*/
const getParentSchemasForComponent = (componentSchema: Schema.Component): Schema.ContentType[] => {
return Object.values(strapi.contentTypes).filter((contentType: any) => {
if (!contentType.options?.draftAndPublish) return false;
return Object.values(contentType.attributes).some((attr: any) => {
return (
(attr.type === 'component' && attr.component === componentSchema.uid) ||
(attr.type === 'dynamiczone' && attr.components?.includes(componentSchema.uid))
);
});
});
};
/**
* Determines if a component relation should be propagated to a new document version
* when a document with draft and publish is updated.
*/
const shouldPropagateComponentRelationToNewVersion = async (
componentRelation: Record<string, any>,
componentSchema: Schema.Component,
parentSchemasForComponent: Schema.ContentType[],
trx: any
): Promise<boolean> => {
// Get the component ID column name using the actual component model name
const componentIdColumn = strapi.db.metadata.identifiers.getJoinColumnAttributeIdName(
_.snakeCase(componentSchema.modelName)
);
const componentId = componentRelation[componentIdColumn];
const parent = await findComponentParent(
componentSchema,
componentId,
parentSchemasForComponent,
{ trx }
);
// Keep relation if component has no parent entry
if (!parent?.uid) {
return true;
}
const parentContentType = strapi.contentTypes[parent.uid as UID.ContentType];
// Keep relation if parent doesn't have draft & publish enabled
if (!parentContentType?.options?.draftAndPublish) {
return true;
}
// Discard relation if parent has draft & publish enabled
return false;
};
/**
* Creates a filter function for component relations that can be passed to the generic
* unidirectional relations utility
*/
const createComponentRelationFilter = () => {
return async (
relation: Record<string, any>,
model: Schema.Component | Schema.ContentType,
trx: any
): Promise<boolean> => {
// Only apply component-specific filtering for components
if (model.modelType !== 'component') {
return true;
}
const componentSchema = model as Schema.Component;
const parentSchemas = getParentSchemasForComponent(componentSchema);
// Exit if no draft & publish parent types exist
if (parentSchemas.length === 0) {
return true;
}
return shouldPropagateComponentRelationToNewVersion(
relation,
componentSchema,
parentSchemas,
trx
);
};
};
export {
omitComponentData,
assignComponentData,
@ -439,4 +597,5 @@ export {
updateComponents,
deleteComponents,
deleteComponent,
createComponentRelationFilter,
};

View File

@ -309,10 +309,16 @@ export const createContentTypeRepository: RepositoryFactoryMethod = (
]);
// Load any unidirectional relation targetting the old published entries
const relationsToSync = await unidirectionalRelations.load(uid, {
const relationsToSync = await unidirectionalRelations.load(
uid,
{
newVersions: draftsToPublish,
oldVersions: oldPublishedVersions,
});
},
{
shouldPropagateRelation: components.createComponentRelationFilter(),
}
);
const bidirectionalRelationsToSync = await bidirectionalRelations.load(uid, {
newVersions: draftsToPublish,
@ -399,10 +405,16 @@ export const createContentTypeRepository: RepositoryFactoryMethod = (
]);
// Load any unidirectional relation targeting the old drafts
const relationsToSync = await unidirectionalRelations.load(uid, {
const relationsToSync = await unidirectionalRelations.load(
uid,
{
newVersions: versionsToDraft,
oldVersions: oldDrafts,
});
},
{
shouldPropagateRelation: components.createComponentRelationFilter(),
}
);
const bidirectionalRelationsToSync = await bidirectionalRelations.load(uid, {
newVersions: versionsToDraft,

View File

@ -3,18 +3,41 @@ import { keyBy, omit } from 'lodash/fp';
import type { UID, Schema } from '@strapi/types';
import type { JoinTable } from '@strapi/database';
interface LoadContext {
oldVersions: { id: string; locale: string }[];
newVersions: { id: string; locale: string }[];
}
interface RelationUpdate {
joinTable: JoinTable;
relations: Record<string, any>[];
}
interface RelationFilterOptions {
/**
* Function to determine if a relation should be propagated to new document versions
* This replaces the hardcoded component-specific logic
*/
shouldPropagateRelation?: (
relation: Record<string, any>,
model: Schema.Component | Schema.ContentType,
trx: any
) => Promise<boolean>;
}
/**
* Loads lingering relations that need to be updated when overriding a published or draft entry.
* This is necessary because the relations are uni-directional and the target entry is not aware of the source entry.
* This is not the case for bi-directional relations, where the target entry is also linked to the source entry.
*/
const load = async (uid: UID.ContentType, { oldVersions, newVersions }: LoadContext) => {
const updates = [] as any;
const load = async (
uid: UID.ContentType,
{ oldVersions, newVersions }: LoadContext,
options: RelationFilterOptions = {}
): Promise<RelationUpdate[]> => {
const updates: RelationUpdate[] = [];
// Iterate all components and content types to find relations that need to be updated
await strapi.db.transaction(async ({ trx }) => {
@ -74,10 +97,10 @@ const load = async (uid: UID.ContentType, { oldVersions, newVersions }: LoadCont
* if published version link exists & not draft version link
* create link to new draft version
*/
if (!model.options?.draftAndPublish) {
const ids = newVersions.map((entry) => entry.id);
// This is the step were we query the join table based on the id of the document
const newVersionsRelations = await strapi.db
.getConnection()
.select('*')
@ -85,19 +108,30 @@ const load = async (uid: UID.ContentType, { oldVersions, newVersions }: LoadCont
.whereIn(targetColumnName, ids)
.transacting(trx);
if (newVersionsRelations.length > 0) {
let versionRelations = newVersionsRelations;
if (options.shouldPropagateRelation) {
const relationsToPropagate = [];
for (const relation of newVersionsRelations) {
if (await options.shouldPropagateRelation(relation, model, trx)) {
relationsToPropagate.push(relation);
}
}
versionRelations = relationsToPropagate;
}
if (versionRelations.length > 0) {
// when publishing a draft that doesn't have a published version yet,
// copy the links to the draft over to the published version
// when discarding a published version, if no drafts exists
const discardToAdd = newVersionsRelations
const discardToAdd = versionRelations
.filter((relation) => {
const matchingOldVerion = oldVersionsRelations.find((oldRelation) => {
const matchingOldVersion = oldVersionsRelations.find((oldRelation) => {
return oldRelation[sourceColumnName] === relation[sourceColumnName];
});
return !matchingOldVerion;
return !matchingOldVersion;
})
.map(omit('id'));
.map(omit(strapi.db.metadata.identifiers.ID_COLUMN));
updates.push({ joinTable, relations: discardToAdd });
}
@ -112,6 +146,10 @@ const load = async (uid: UID.ContentType, { oldVersions, newVersions }: LoadCont
/**
* Updates uni directional relations to target the right entries when overriding published or draft entries.
*
* This function:
* 1. Creates new relations pointing to the new entry versions
* 2. Precisely deletes only the old relations being replaced to prevent orphaned links
*
* @param oldEntries The old entries that are being overridden
* @param newEntries The new entries that are overriding the old ones
* @param oldRelations The relations that were previously loaded with `load` @see load
@ -155,3 +193,4 @@ const sync = async (
};
export { load, sync };
export type { RelationFilterOptions };

View File

@ -1,6 +1,7 @@
import type { Knex } from 'knex';
import path from 'node:path';
import { Dialect, getDialect } from './dialects';
import { createSchemaProvider, SchemaProvider } from './schema';
import { createMetadata, Metadata } from './metadata';
@ -11,7 +12,7 @@ import { createConnection } from './connection';
import * as errors from './errors';
import { Callback, transactionCtx, TransactionObject } from './transaction-context';
import { validateDatabase } from './validations';
import type { Model } from './types';
import type { Model, JoinTable } from './types';
import type { Identifiers } from './utils/identifiers';
import { createRepairManager, type RepairManager } from './repairs';
@ -263,4 +264,4 @@ class Database {
}
export { Database, errors };
export type { Model, Identifiers, Migration };
export type { Model, JoinTable, Identifiers, Migration };

View File

@ -24,17 +24,24 @@ const populate = {
compo: {
populate: {
tag: true,
tags: true,
},
},
};
const componentModel = {
collectionName: 'components_compo',
attributes: {
tag: {
type: 'relation',
relation: 'oneToOne',
target: TAG_UID,
},
tags: {
type: 'relation',
relation: 'oneToMany',
target: TAG_UID,
},
},
displayName: 'compo',
};
@ -164,4 +171,114 @@ describe('Document Service unidirectional relations', () => {
compo: { tag: { id: tag1Id } },
});
});
it('Should not create orphaned relations for a draft and publish content-type when updating from the parent side', async () => {
const joinTableName = 'components_default_compos_tags_lnk';
// Step 1: Create Product with component tag relation (draft)
const testProduct = await strapi.documents(PRODUCT_UID).create({
data: {
name: 'GhostRelationBugTest',
compo: {
tags: [{ documentId: 'Tag3' }], // Component relation to Tag (still in draft)
},
},
});
// Check join table after step 1
let result = await strapi.db.connection.raw(`SELECT * FROM ${joinTableName}`);
let joinTableRows = Array.isArray(result) ? result : result.rows || result;
// 1 entry is created for draft to draft
expect(joinTableRows.length).toBe(1);
// Step 2: Publish Tag FIRST - this triggers ghost relation creation
await strapi.documents(TAG_UID).publish({ documentId: 'Tag3' });
// Check join table after step 2
result = await strapi.db.connection.raw(`SELECT * FROM ${joinTableName}`);
joinTableRows = Array.isArray(result) ? result : result.rows || result;
// No new entry should be created in the join table
expect(joinTableRows.length).toBe(1);
// Step 3: Publish Product - creates published component version
await strapi.documents(PRODUCT_UID).publish({ documentId: testProduct.documentId });
// Check join table after step 3
result = await strapi.db.connection.raw(`SELECT * FROM ${joinTableName}`);
joinTableRows = Array.isArray(result) ? result : result.rows || result;
// 1 entry should be created (2 total) in the join table for published to published
expect(joinTableRows.length).toBe(2);
// Cleanup - Delete the entry
await strapi.documents(PRODUCT_UID).delete({ documentId: testProduct.documentId });
result = await strapi.db.connection.raw(`SELECT * FROM ${joinTableName}`);
joinTableRows = Array.isArray(result) ? result : result.rows || result;
expect(joinTableRows.length).toBe(0);
});
it('Should not create orphaned relations for a draft and publish content-type when updating from the relation side', async () => {
const joinTableName = 'components_default_compos_tags_lnk';
// Step 1: Create and publish a tag
await strapi.documents(TAG_UID).create({
data: {
name: 'Tag4',
documentId: 'Tag4',
},
});
const tag = await strapi.documents(TAG_UID).publish({ documentId: 'Tag4' });
const tagId = tag.entries[0].id;
// Step 2: Create Product with component tag relation (published)
const testProduct = await strapi.documents(PRODUCT_UID).create({
data: {
name: 'GhostRelationBugTest',
compo: {
tags: [{ id: tagId }], // Component relation to Tag (published)
},
},
});
// Step 3: Poublish the product
await strapi.documents(PRODUCT_UID).publish({ documentId: testProduct.documentId });
// Check join table after step 1
let result = await strapi.db.connection.raw(`SELECT * FROM ${joinTableName}`);
let joinTableRows = Array.isArray(result) ? result : result.rows || result;
// Expect 2 entries (draft to draft, published to published)
expect(joinTableRows.length).toBe(2);
// Step 4: Update the tag and publish
await strapi.documents(TAG_UID).update({ documentId: 'Tag4', name: 'Tag4 update' });
await strapi.documents(TAG_UID).publish({ documentId: 'Tag4' });
// Check join table after step 4
result = await strapi.db.connection.raw(`SELECT * FROM ${joinTableName}`);
joinTableRows = Array.isArray(result) ? result : result.rows || result;
// No new entry should be created in the join table
expect(joinTableRows.length).toBe(2);
// Step 5: Republish the parent
await strapi.documents(PRODUCT_UID).publish({ documentId: testProduct.documentId });
// Check join table after step 5
result = await strapi.db.connection.raw(`SELECT * FROM ${joinTableName}`);
joinTableRows = Array.isArray(result) ? result : result.rows || result;
// No new entry should be created in the join table
expect(joinTableRows.length).toBe(2);
// Cleanup - Delete the entry
await strapi.documents(PRODUCT_UID).delete({ documentId: testProduct.documentId });
result = await strapi.db.connection.raw(`SELECT * FROM ${joinTableName}`);
joinTableRows = Array.isArray(result) ? result : result.rows || result;
expect(joinTableRows.length).toBe(0);
});
});