diff --git a/packages/core/core/src/services/document-service/components.ts b/packages/core/core/src/services/document-service/components.ts index bae7899704..947de8202a 100644 --- a/packages/core/core/src/services/document-service/components.ts +++ b/packages/core/core/src/services/document-service/components.ts @@ -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 = Modules.Documents.Params.Data.Input; @@ -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, + componentSchema: Schema.Component, + parentSchemasForComponent: Schema.ContentType[], + trx: any +): Promise => { + // 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, + model: Schema.Component | Schema.ContentType, + trx: any + ): Promise => { + // 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, }; diff --git a/packages/core/core/src/services/document-service/repository.ts b/packages/core/core/src/services/document-service/repository.ts index a60f292908..b5abf86a5b 100644 --- a/packages/core/core/src/services/document-service/repository.ts +++ b/packages/core/core/src/services/document-service/repository.ts @@ -309,10 +309,16 @@ export const createContentTypeRepository: RepositoryFactoryMethod = ( ]); // Load any unidirectional relation targetting the old published entries - const relationsToSync = await unidirectionalRelations.load(uid, { - newVersions: draftsToPublish, - oldVersions: oldPublishedVersions, - }); + 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, { - newVersions: versionsToDraft, - oldVersions: oldDrafts, - }); + const relationsToSync = await unidirectionalRelations.load( + uid, + { + newVersions: versionsToDraft, + oldVersions: oldDrafts, + }, + { + shouldPropagateRelation: components.createComponentRelationFilter(), + } + ); const bidirectionalRelationsToSync = await bidirectionalRelations.load(uid, { newVersions: versionsToDraft, diff --git a/packages/core/core/src/services/document-service/utils/unidirectional-relations.ts b/packages/core/core/src/services/document-service/utils/unidirectional-relations.ts index 660108d78a..21f1071495 100644 --- a/packages/core/core/src/services/document-service/utils/unidirectional-relations.ts +++ b/packages/core/core/src/services/document-service/utils/unidirectional-relations.ts @@ -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[]; +} + +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, + model: Schema.Component | Schema.ContentType, + trx: any + ) => Promise; +} + /** * 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 => { + 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 }; diff --git a/packages/core/database/src/index.ts b/packages/core/database/src/index.ts index 9eec1f38f5..0b0f19d2ba 100644 --- a/packages/core/database/src/index.ts +++ b/packages/core/database/src/index.ts @@ -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 }; diff --git a/tests/api/core/strapi/document-service/relations/unidirectional-relations.test.api.ts b/tests/api/core/strapi/document-service/relations/unidirectional-relations.test.api.ts index 82f750063c..ffcbb23502 100644 --- a/tests/api/core/strapi/document-service/relations/unidirectional-relations.test.api.ts +++ b/tests/api/core/strapi/document-service/relations/unidirectional-relations.test.api.ts @@ -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); + }); });