mirror of
https://github.com/strapi/strapi.git
synced 2025-09-22 06:50:51 +00:00
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:
parent
80e8a7c212
commit
a1da9b829e
@ -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,
|
||||
};
|
||||
|
@ -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,
|
||||
|
@ -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 };
|
||||
|
@ -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 };
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
Loading…
x
Reference in New Issue
Block a user