mirror of
https://github.com/strapi/strapi.git
synced 2025-11-02 10:55:37 +00:00
fix: database is corrupt with orphaned relations (#24316)
* fix: database is corrupt with orphaned relations * chore: move script to document service and refactor * fix: refactoring cleanup script * fix: some improvements * test(api): add unidirectional join table repair tests * test(api): remove debug logs * fix: orphaned relation nested compo (#24387) --------- Co-authored-by: Bassel Kanso <basselkanso82@gmail.com> Co-authored-by: Ben Irvin <ben@innerdvations.com> Co-authored-by: Ben Irvin <ben.irvin@strapi.io>
This commit is contained in:
parent
aac81a07a7
commit
536eed0201
@ -37,6 +37,8 @@ import { createContentSourceMapsService } from './services/content-source-maps';
|
||||
import { coreStoreModel } from './services/core-store';
|
||||
import { createConfigProvider } from './services/config';
|
||||
|
||||
import { cleanComponentJoinTable } from './services/document-service/utils/clean-component-join-table';
|
||||
|
||||
class Strapi extends Container implements Core.Strapi {
|
||||
app: any;
|
||||
|
||||
@ -450,6 +452,20 @@ class Strapi extends Container implements Core.Strapi {
|
||||
await this.db.repair.removeOrphanMorphType({ pivot: 'component_type' });
|
||||
}
|
||||
|
||||
const alreadyRanComponentRepair = await this.store.get({
|
||||
type: 'strapi',
|
||||
key: 'unidirectional-join-table-repair-ran',
|
||||
});
|
||||
|
||||
if (!alreadyRanComponentRepair) {
|
||||
await this.db.repair.processUnidirectionalJoinTables(cleanComponentJoinTable);
|
||||
await this.store.set({
|
||||
type: 'strapi',
|
||||
key: 'unidirectional-join-table-repair-ran',
|
||||
value: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (this.EE) {
|
||||
await utils.ee.checkLicense({ strapi: this });
|
||||
}
|
||||
|
||||
@ -455,7 +455,7 @@ const assignComponentData = curry(
|
||||
const findComponentParent = async (
|
||||
componentSchema: Schema.Component,
|
||||
componentId: number | string,
|
||||
parentSchemasForComponent: Schema.ContentType[],
|
||||
parentSchemasForComponent: (Schema.ContentType | Schema.Component)[],
|
||||
opts?: { trx?: any }
|
||||
): Promise<{ uid: string; table: string; parentId: number | string } | null> => {
|
||||
if (!componentSchema?.uid) return null;
|
||||
@ -504,17 +504,21 @@ const findComponentParent = async (
|
||||
/**
|
||||
* 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))
|
||||
);
|
||||
});
|
||||
});
|
||||
const getParentSchemasForComponent = (
|
||||
componentSchema: Schema.Component
|
||||
): Array<Schema.ContentType | Schema.Component> => {
|
||||
// Find direct parents in contentTypes and components
|
||||
return [...Object.values(strapi.contentTypes), ...Object.values(strapi.components)].filter(
|
||||
(schema: any) => {
|
||||
if (!schema?.attributes) return false;
|
||||
return Object.values(schema.attributes).some((attr: any) => {
|
||||
return (
|
||||
(attr.type === 'component' && attr.component === componentSchema.uid) ||
|
||||
(attr.type === 'dynamiczone' && attr.components?.includes(componentSchema.uid))
|
||||
);
|
||||
});
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
@ -524,7 +528,7 @@ const getParentSchemasForComponent = (componentSchema: Schema.Component): Schema
|
||||
const shouldPropagateComponentRelationToNewVersion = async (
|
||||
componentRelation: Record<string, any>,
|
||||
componentSchema: Schema.Component,
|
||||
parentSchemasForComponent: Schema.ContentType[],
|
||||
parentSchemasForComponent: (Schema.ContentType | Schema.Component)[],
|
||||
trx: any
|
||||
): Promise<boolean> => {
|
||||
// Get the component ID column name using the actual component model name
|
||||
@ -532,7 +536,7 @@ const shouldPropagateComponentRelationToNewVersion = async (
|
||||
_.snakeCase(componentSchema.modelName)
|
||||
);
|
||||
|
||||
const componentId = componentRelation[componentIdColumn];
|
||||
const componentId = componentRelation[componentIdColumn] ?? componentRelation.parentId;
|
||||
|
||||
const parent = await findComponentParent(
|
||||
componentSchema,
|
||||
@ -546,6 +550,18 @@ const shouldPropagateComponentRelationToNewVersion = async (
|
||||
return true;
|
||||
}
|
||||
|
||||
if (strapi.components[parent.uid as UID.Component]) {
|
||||
// If the parent is a component, we need to check its parents recursively
|
||||
const parentComponentSchema = strapi.components[parent.uid as UID.Component];
|
||||
const grandParentSchemas = getParentSchemasForComponent(parentComponentSchema);
|
||||
return shouldPropagateComponentRelationToNewVersion(
|
||||
parent,
|
||||
parentComponentSchema,
|
||||
grandParentSchemas,
|
||||
trx
|
||||
);
|
||||
}
|
||||
|
||||
const parentContentType = strapi.contentTypes[parent.uid as UID.ContentType];
|
||||
|
||||
// Keep relation if parent doesn't have draft & publish enabled
|
||||
@ -598,4 +614,6 @@ export {
|
||||
deleteComponents,
|
||||
deleteComponent,
|
||||
createComponentRelationFilter,
|
||||
findComponentParent,
|
||||
getParentSchemasForComponent,
|
||||
};
|
||||
|
||||
@ -0,0 +1,217 @@
|
||||
import type { Database } from '@strapi/database';
|
||||
import type { Schema } from '@strapi/types';
|
||||
import { findComponentParent, getParentSchemasForComponent } from '../components';
|
||||
|
||||
/**
|
||||
* Cleans ghost relations with publication state mismatches from a join table
|
||||
* Uses schema-based draft/publish checking like prevention fix
|
||||
*/
|
||||
export const cleanComponentJoinTable = async (
|
||||
db: Database,
|
||||
joinTableName: string,
|
||||
relation: any,
|
||||
sourceModel: any
|
||||
): Promise<number> => {
|
||||
try {
|
||||
// Get the target model metadata
|
||||
const targetModel = db.metadata.get(relation.target);
|
||||
if (!targetModel) {
|
||||
db.logger.debug(`Target model ${relation.target} not found, skipping ${joinTableName}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Check if target supports draft/publish using schema-based approach (like prevention fix)
|
||||
const targetContentType =
|
||||
strapi.contentTypes[relation.target as keyof typeof strapi.contentTypes];
|
||||
const targetSupportsDraftPublish = targetContentType?.options?.draftAndPublish || false;
|
||||
|
||||
if (!targetSupportsDraftPublish) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Find entries with publication state mismatches
|
||||
const ghostEntries = await findPublicationStateMismatches(
|
||||
db,
|
||||
joinTableName,
|
||||
relation,
|
||||
targetModel,
|
||||
sourceModel
|
||||
);
|
||||
|
||||
if (ghostEntries.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Remove ghost entries
|
||||
await db.connection(joinTableName).whereIn('id', ghostEntries).del();
|
||||
db.logger.debug(
|
||||
`Removed ${ghostEntries.length} ghost relations with publication state mismatches from ${joinTableName}`
|
||||
);
|
||||
|
||||
return ghostEntries.length;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
db.logger.error(`Failed to clean join table "${joinTableName}": ${errorMessage}`);
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
const findContentTypeParentForComponentInstance = async (
|
||||
componentSchema: Schema.Component,
|
||||
componentId: number | string
|
||||
): Promise<{ uid: string; table: string; parentId: number | string } | null> => {
|
||||
// Get the parent schemas that could contain this component
|
||||
const parentSchemas = getParentSchemasForComponent(componentSchema);
|
||||
if (parentSchemas.length === 0) {
|
||||
// No potential parents
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find the actual parent for THIS specific component instance
|
||||
const parent = await findComponentParent(componentSchema, componentId, parentSchemas);
|
||||
if (!parent) {
|
||||
// No parent found for this component instance
|
||||
return null;
|
||||
}
|
||||
|
||||
if (strapi.components[parent.uid as keyof typeof strapi.components]) {
|
||||
// If the parent is a component, we need to check its parents recursively
|
||||
const parentComponentSchema = strapi.components[parent.uid as keyof typeof strapi.components];
|
||||
return findContentTypeParentForComponentInstance(parentComponentSchema, parent.parentId);
|
||||
}
|
||||
|
||||
if (strapi.contentTypes[parent.uid as keyof typeof strapi.contentTypes]) {
|
||||
// Found a content type parent
|
||||
return parent;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Finds join table entries with publication state mismatches
|
||||
* Uses existing component parent detection from document service
|
||||
*/
|
||||
const findPublicationStateMismatches = async (
|
||||
db: Database,
|
||||
joinTableName: string,
|
||||
relation: any,
|
||||
targetModel: any,
|
||||
sourceModel: any
|
||||
): Promise<number[]> => {
|
||||
try {
|
||||
// Get join column names using proper functions (addressing PR feedback)
|
||||
const sourceColumn = relation.joinTable.joinColumn.name;
|
||||
const targetColumn = relation.joinTable.inverseJoinColumn.name;
|
||||
|
||||
// Get all join entries with their target entities
|
||||
const query = db
|
||||
.connection(joinTableName)
|
||||
.select(
|
||||
`${joinTableName}.id as join_id`,
|
||||
`${joinTableName}.${sourceColumn} as source_id`,
|
||||
`${joinTableName}.${targetColumn} as target_id`,
|
||||
`${targetModel.tableName}.published_at as target_published_at`
|
||||
)
|
||||
.leftJoin(
|
||||
targetModel.tableName,
|
||||
`${joinTableName}.${targetColumn}`,
|
||||
`${targetModel.tableName}.id`
|
||||
);
|
||||
|
||||
const joinEntries = await query;
|
||||
|
||||
// Group by source_id to find duplicates pointing to draft/published versions of same entity
|
||||
const entriesBySource: { [key: string]: any[] } = {};
|
||||
for (const entry of joinEntries) {
|
||||
const sourceId = entry.source_id;
|
||||
if (!entriesBySource[sourceId]) {
|
||||
entriesBySource[sourceId] = [];
|
||||
}
|
||||
entriesBySource[sourceId].push(entry);
|
||||
}
|
||||
|
||||
const ghostEntries: number[] = [];
|
||||
|
||||
// Check if this is a join table (ends with _lnk)
|
||||
const isRelationJoinTable = joinTableName.endsWith('_lnk');
|
||||
const isComponentModel =
|
||||
!sourceModel.uid?.startsWith('api::') &&
|
||||
!sourceModel.uid?.startsWith('plugin::') &&
|
||||
sourceModel.uid?.includes('.');
|
||||
|
||||
// Check for draft/publish inconsistencies
|
||||
for (const [sourceId, entries] of Object.entries(entriesBySource)) {
|
||||
// Skip entries with single relations
|
||||
if (entries.length <= 1) {
|
||||
// eslint-disable-next-line no-continue
|
||||
continue;
|
||||
}
|
||||
|
||||
// For component join tables, check if THIS specific component instance's parent supports D&P
|
||||
if (isRelationJoinTable && isComponentModel) {
|
||||
try {
|
||||
const componentSchema = strapi.components[sourceModel.uid] as Schema.Component;
|
||||
if (!componentSchema) {
|
||||
// eslint-disable-next-line no-continue
|
||||
continue;
|
||||
}
|
||||
|
||||
const parent = await findContentTypeParentForComponentInstance(componentSchema, sourceId);
|
||||
if (!parent) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if THIS component instance's parent supports draft/publish
|
||||
const parentContentType =
|
||||
strapi.contentTypes[parent.uid as keyof typeof strapi.contentTypes];
|
||||
if (!parentContentType?.options?.draftAndPublish) {
|
||||
// This component instance's parent does NOT support D&P - skip cleanup
|
||||
// eslint-disable-next-line no-continue
|
||||
continue;
|
||||
}
|
||||
|
||||
// If we reach here, this component instance's parent DOES support D&P
|
||||
// Continue to process this component instance for ghost relations
|
||||
} catch (error) {
|
||||
// Skip this component instance on error
|
||||
// eslint-disable-next-line no-continue
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Find ghost relations (same logic as original but with improved parent checking)
|
||||
for (const entry of entries) {
|
||||
if (entry.target_published_at === null) {
|
||||
// This is a draft target - find its published version
|
||||
const draftTarget = await db
|
||||
.connection(targetModel.tableName)
|
||||
.select('document_id')
|
||||
.where('id', entry.target_id)
|
||||
.first();
|
||||
|
||||
if (draftTarget) {
|
||||
const publishedVersion = await db
|
||||
.connection(targetModel.tableName)
|
||||
.select('id', 'document_id')
|
||||
.where('document_id', draftTarget.document_id)
|
||||
.whereNotNull('published_at')
|
||||
.first();
|
||||
|
||||
if (publishedVersion) {
|
||||
// Check if we also have a relation to the published version
|
||||
const publishedRelation = entries.find((e) => e.target_id === publishedVersion.id);
|
||||
if (publishedRelation) {
|
||||
ghostEntries.push(publishedRelation.join_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ghostEntries;
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
@ -1,10 +1,12 @@
|
||||
import type { Database } from '..';
|
||||
import { removeOrphanMorphType as removeOrphanMorphTypeFunc } from './operations/remove-orphan-morph-types';
|
||||
import { processUnidirectionalJoinTables } from './operations/process-unidirectional-join-tables';
|
||||
import { asyncCurry } from '../utils/async-curry';
|
||||
|
||||
export const createRepairManager = (db: Database) => {
|
||||
return {
|
||||
removeOrphanMorphType: asyncCurry(removeOrphanMorphTypeFunc)(db),
|
||||
processUnidirectionalJoinTables: asyncCurry(processUnidirectionalJoinTables)(db),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@ -0,0 +1,79 @@
|
||||
import type { Database } from '../..';
|
||||
|
||||
/**
|
||||
* Iterates over all models and their unidirectional relations, invoking a provided operation on each join table.
|
||||
*
|
||||
* This function does not perform any cleaning or modification itself. Instead, it identifies all unidirectional
|
||||
* relations (relations without inversedBy or mappedBy) that use join tables, and delegates any join table operation
|
||||
* (such as cleaning, validation, or analysis) to the provided operateOnJoinTable function.
|
||||
*
|
||||
* @param db - The database instance
|
||||
* @param operateOnJoinTable - A function to execute for each unidirectional join table relation
|
||||
* @returns The sum of results returned by operateOnJoinTable for all processed relations
|
||||
*/
|
||||
export const processUnidirectionalJoinTables = async (
|
||||
db: Database,
|
||||
operateOnJoinTable: (
|
||||
db: Database,
|
||||
joinTableName: string,
|
||||
relation: any,
|
||||
sourceModel: any
|
||||
) => Promise<number>
|
||||
): Promise<number> => {
|
||||
let totalResult = 0;
|
||||
|
||||
const mdValues = db.metadata.values();
|
||||
const mdArray = Array.from(mdValues);
|
||||
|
||||
if (mdArray.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
db.logger.debug('Starting unidirectional join table operation');
|
||||
|
||||
for (const model of mdArray) {
|
||||
const unidirectionalRelations = getUnidirectionalRelations(model.attributes || {});
|
||||
|
||||
for (const relation of unidirectionalRelations) {
|
||||
if (hasJoinTable(relation) && hasTarget(relation)) {
|
||||
const result = await operateOnJoinTable(db, relation.joinTable.name, relation, model);
|
||||
totalResult += result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
db.logger.debug(
|
||||
`Unidirectional join table operation completed. Processed ${totalResult} entries.`
|
||||
);
|
||||
|
||||
return totalResult;
|
||||
};
|
||||
|
||||
/**
|
||||
* Identifies unidirectional relations (relations without inversedBy or mappedBy)
|
||||
* Uses same logic as prevention fix in unidirectional-relations.ts:54-61
|
||||
*/
|
||||
const getUnidirectionalRelations = (attributes: Record<string, any>): any[] => {
|
||||
return Object.values(attributes).filter((attribute) => {
|
||||
if (attribute.type !== 'relation') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if it's unidirectional (no inversedBy or mappedBy) - same as prevention logic
|
||||
return !attribute.inversedBy && !attribute.mappedBy;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Type guard to check if a relation has a joinTable property
|
||||
*/
|
||||
const hasJoinTable = (relation: any): boolean => {
|
||||
return 'joinTable' in relation && relation.joinTable != null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Type guard to check if a relation has a target property
|
||||
*/
|
||||
const hasTarget = (relation: any): boolean => {
|
||||
return 'target' in relation && typeof relation.target === 'string';
|
||||
};
|
||||
@ -17,6 +17,7 @@ let rq;
|
||||
|
||||
const PRODUCT_UID = 'api::product.product';
|
||||
const TAG_UID = 'api::tag.tag';
|
||||
const INNER_COMPONENT_UID = 'default.inner';
|
||||
|
||||
const populate = {
|
||||
tag: true,
|
||||
@ -25,10 +26,27 @@ const populate = {
|
||||
populate: {
|
||||
tag: true,
|
||||
tags: true,
|
||||
inner: {
|
||||
populate: {
|
||||
tags: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const innerComponentModel = {
|
||||
collectionName: 'components_inner',
|
||||
attributes: {
|
||||
tags: {
|
||||
type: 'relation',
|
||||
relation: 'oneToMany',
|
||||
target: TAG_UID,
|
||||
},
|
||||
},
|
||||
displayName: 'inner',
|
||||
};
|
||||
|
||||
const componentModel = {
|
||||
collectionName: 'components_compo',
|
||||
attributes: {
|
||||
@ -42,6 +60,10 @@ const componentModel = {
|
||||
relation: 'oneToMany',
|
||||
target: TAG_UID,
|
||||
},
|
||||
inner: {
|
||||
type: 'component',
|
||||
component: INNER_COMPONENT_UID,
|
||||
},
|
||||
},
|
||||
displayName: 'compo',
|
||||
};
|
||||
@ -92,6 +114,7 @@ describe('Document Service unidirectional relations', () => {
|
||||
beforeAll(async () => {
|
||||
await builder
|
||||
.addContentTypes([tagModel])
|
||||
.addComponent(innerComponentModel)
|
||||
.addComponent(componentModel)
|
||||
.addContentTypes([productModel])
|
||||
.build();
|
||||
@ -114,7 +137,10 @@ describe('Document Service unidirectional relations', () => {
|
||||
name: 'Product1',
|
||||
tag: { documentId: 'Tag1' },
|
||||
tags: [{ documentId: 'Tag1' }, { documentId: 'Tag2' }],
|
||||
compo: { tag: { documentId: 'Tag1' } },
|
||||
compo: {
|
||||
tag: { documentId: 'Tag1' },
|
||||
inner: { tags: [{ documentId: 'Tag1' }, { documentId: 'Tag2' }] },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@ -146,7 +172,7 @@ describe('Document Service unidirectional relations', () => {
|
||||
name: 'Product1',
|
||||
tag: { id: tag1Id },
|
||||
tags: [{ id: tag1Id }, { id: tag2Id }],
|
||||
compo: { tag: { id: tag1Id } },
|
||||
compo: { tag: { id: tag1Id }, inner: { tags: [{ id: tag1Id }, { id: tag2Id }] } },
|
||||
});
|
||||
});
|
||||
|
||||
@ -168,7 +194,7 @@ describe('Document Service unidirectional relations', () => {
|
||||
name: 'Product1',
|
||||
tag: { id: tag1Id },
|
||||
tags: [{ id: tag1Id }, { id: tag2Id }],
|
||||
compo: { tag: { id: tag1Id } },
|
||||
compo: { tag: { id: tag1Id }, inner: { tags: [{ id: tag1Id }, { id: tag2Id }] } },
|
||||
});
|
||||
});
|
||||
|
||||
@ -281,4 +307,144 @@ describe('Document Service unidirectional relations', () => {
|
||||
joinTableRows = Array.isArray(result) ? result : result.rows || result;
|
||||
expect(joinTableRows.length).toBe(0);
|
||||
});
|
||||
|
||||
// Helpers to resolve the nested component (inner) join table info at runtime
|
||||
const getInnerJoinInfo = () => {
|
||||
const md: any = (strapi as any).db.metadata.get(INNER_COMPONENT_UID);
|
||||
const relation: any = md.attributes?.tags;
|
||||
if (!relation?.joinTable?.name) throw new Error('Inner component join table not found');
|
||||
const joinTableName = relation.joinTable.name as string;
|
||||
const targetColumn = relation.joinTable.inverseJoinColumn.name as string;
|
||||
return { joinTableName, targetColumn } as const;
|
||||
};
|
||||
|
||||
it('Should not create orphaned relations for nested component when updating from the parent side', async () => {
|
||||
const { joinTableName, targetColumn } = getInnerJoinInfo();
|
||||
|
||||
// Step 1: Create Product with nested component tag relation (draft)
|
||||
const testProduct = await strapi.documents(PRODUCT_UID).create({
|
||||
data: {
|
||||
name: 'NestedGhostRelationParent',
|
||||
compo: { inner: { tags: [{ documentId: 'Tag3' }] } },
|
||||
},
|
||||
});
|
||||
|
||||
// Load Tag3 versions and filter join table rows by Tag3 document ids
|
||||
let tag3Versions = await strapi.db.query(TAG_UID).findMany({ where: { documentId: 'Tag3' } });
|
||||
const tag3Draft = tag3Versions.find((t: any) => t.publishedAt === null)!;
|
||||
const tag3Published = tag3Versions.find((t: any) => t.publishedAt !== null);
|
||||
|
||||
// 1 entry is created for draft to draft (only draft id exists at this stage)
|
||||
let joinTableRows = await strapi.db
|
||||
.connection(joinTableName)
|
||||
.select('*')
|
||||
.whereIn(targetColumn, [tag3Draft.id]);
|
||||
expect(joinTableRows.length).toBe(1);
|
||||
|
||||
// Step 2: Publish Tag FIRST
|
||||
await strapi.documents(TAG_UID).publish({ documentId: 'Tag3' });
|
||||
|
||||
tag3Versions = await strapi.db.query(TAG_UID).findMany({ where: { documentId: 'Tag3' } });
|
||||
const tag3DraftAfter = tag3Versions.find((t: any) => t.publishedAt === null)!;
|
||||
const tag3PubAfter = tag3Versions.find((t: any) => t.publishedAt !== null)!;
|
||||
|
||||
// Still 1 row for Tag3 (should not create extra rows yet)
|
||||
joinTableRows = await strapi.db
|
||||
.connection(joinTableName)
|
||||
.select('*')
|
||||
.whereIn(targetColumn, [tag3DraftAfter.id, tag3PubAfter.id]);
|
||||
expect(joinTableRows.length).toBe(1);
|
||||
|
||||
// Step 3: Publish Product - creates published component version
|
||||
await strapi.documents(PRODUCT_UID).publish({ documentId: testProduct.documentId });
|
||||
|
||||
joinTableRows = await strapi.db
|
||||
.connection(joinTableName)
|
||||
.select('*')
|
||||
.whereIn(targetColumn, [tag3DraftAfter.id, tag3PubAfter.id]);
|
||||
expect(joinTableRows.length).toBe(2);
|
||||
|
||||
// Cleanup
|
||||
await strapi.documents(PRODUCT_UID).delete({ documentId: testProduct.documentId });
|
||||
joinTableRows = await strapi.db
|
||||
.connection(joinTableName)
|
||||
.select('*')
|
||||
.whereIn(targetColumn, [tag3DraftAfter.id, tag3PubAfter.id]);
|
||||
expect(joinTableRows.length).toBe(0);
|
||||
});
|
||||
|
||||
it('Should not create orphaned relations for nested component when updating from the relation side', async () => {
|
||||
const { joinTableName, targetColumn } = getInnerJoinInfo();
|
||||
|
||||
// Step 1: Create and publish a tag
|
||||
await strapi.documents(TAG_UID).create({ data: { name: 'Tag5', documentId: 'Tag5' } });
|
||||
const tag = await strapi.documents(TAG_UID).publish({ documentId: 'Tag5' });
|
||||
const tagPublishedId = tag.entries[0].id;
|
||||
const tag5Versions = await strapi.db.query(TAG_UID).findMany({ where: { documentId: 'Tag5' } });
|
||||
const tag5Draft = tag5Versions.find((t: any) => t.publishedAt === null)!;
|
||||
const tag5Published = tag5Versions.find((t: any) => t.publishedAt !== null)!;
|
||||
|
||||
// Step 2: Create Product with nested component tag relation (published)
|
||||
const testProduct = await strapi.documents(PRODUCT_UID).create({
|
||||
data: {
|
||||
name: 'NestedGhostRelationRel',
|
||||
compo: { inner: { tags: [{ id: tagPublishedId }] } },
|
||||
},
|
||||
});
|
||||
|
||||
// Step 3: Publish the product
|
||||
await strapi.documents(PRODUCT_UID).publish({ documentId: testProduct.documentId });
|
||||
|
||||
// Expect 2 entries (draft and published) for Tag5 document (either id)
|
||||
let joinTableRows = await strapi.db
|
||||
.connection(joinTableName)
|
||||
.select('*')
|
||||
.whereIn(targetColumn, [tag5Draft.id, tag5Published.id]);
|
||||
expect(joinTableRows.length).toBe(2);
|
||||
|
||||
// Step 4: Update the tag and publish
|
||||
await strapi.documents(TAG_UID).update({ documentId: 'Tag5', name: 'Tag5 update' });
|
||||
await strapi.documents(TAG_UID).publish({ documentId: 'Tag5' });
|
||||
|
||||
// Re-fetch Tag5 versions since publish created new ids
|
||||
{
|
||||
const updatedTag5Versions = await strapi.db
|
||||
.query(TAG_UID)
|
||||
.findMany({ where: { documentId: 'Tag5' } });
|
||||
const newDraft = updatedTag5Versions.find((t: any) => t.publishedAt === null)!;
|
||||
const newPublished = updatedTag5Versions.find((t: any) => t.publishedAt !== null)!;
|
||||
|
||||
joinTableRows = await strapi.db
|
||||
.connection(joinTableName)
|
||||
.select('*')
|
||||
.whereIn(targetColumn, [newDraft.id, newPublished.id]);
|
||||
expect(joinTableRows.length).toBe(2);
|
||||
}
|
||||
|
||||
// Step 5: Republish the parent
|
||||
await strapi.documents(PRODUCT_UID).publish({ documentId: testProduct.documentId });
|
||||
|
||||
// Re-check using current Tag5 ids after republishing the parent
|
||||
{
|
||||
const currentTag5Versions = await strapi.db
|
||||
.query(TAG_UID)
|
||||
.findMany({ where: { documentId: 'Tag5' } });
|
||||
const curDraft = currentTag5Versions.find((t: any) => t.publishedAt === null)!;
|
||||
const curPublished = currentTag5Versions.find((t: any) => t.publishedAt !== null)!;
|
||||
|
||||
joinTableRows = await strapi.db
|
||||
.connection(joinTableName)
|
||||
.select('*')
|
||||
.whereIn(targetColumn, [curDraft.id, curPublished.id]);
|
||||
expect(joinTableRows.length).toBe(2);
|
||||
}
|
||||
|
||||
// Cleanup - Delete the entry
|
||||
await strapi.documents(PRODUCT_UID).delete({ documentId: testProduct.documentId });
|
||||
joinTableRows = await strapi.db
|
||||
.connection(joinTableName)
|
||||
.select('*')
|
||||
.whereIn(targetColumn, [tag5Draft.id, tag5Published.id]);
|
||||
expect(joinTableRows.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@ -0,0 +1,725 @@
|
||||
/**
|
||||
* API tests for the unidirectional join-table repair.
|
||||
*
|
||||
* These tests insert bad data directly into the DB to simulate the original bug:
|
||||
* duplicated component join-table rows pointing to both the draft and the
|
||||
* published versions of the same target document for a single component instance.
|
||||
*
|
||||
* We validate that:
|
||||
* - Only the unintended duplicate is removed (published row when a draft row also exists)
|
||||
* - Legitimate rows are never deleted (single relations remain)
|
||||
* - If the component's parent does not support D&P, nothing is deleted
|
||||
*/
|
||||
import type { Core } from '@strapi/types';
|
||||
// Note: Avoid wrapping in a transaction to prevent pool deadlocks with SQLite
|
||||
|
||||
const { createTestBuilder } = require('api-tests/builder');
|
||||
const { createStrapiInstance } = require('api-tests/strapi');
|
||||
|
||||
// Local cleaner used for tests to have full control and avoid coupling to core internals
|
||||
const testCleaner = async (
|
||||
db: any,
|
||||
joinTableName: string,
|
||||
relation: any,
|
||||
sourceModel: any
|
||||
): Promise<number> => {
|
||||
try {
|
||||
const targetModel = db.metadata.get(relation.target);
|
||||
if (!targetModel) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Check if target supports D&P
|
||||
const targetCt = (strapi as any).contentTypes?.[relation.target];
|
||||
const targetSupportsDP = !!targetCt?.options?.draftAndPublish;
|
||||
if (!targetSupportsDP) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Column names
|
||||
const sourceColumn = relation.joinTable.joinColumn.name;
|
||||
const targetColumn = relation.joinTable.inverseJoinColumn.name;
|
||||
|
||||
// Load join rows with target published_at
|
||||
const rows = await db
|
||||
.connection(joinTableName)
|
||||
.select(
|
||||
`${joinTableName}.id as join_id`,
|
||||
`${joinTableName}.${sourceColumn} as source_id`,
|
||||
`${joinTableName}.${targetColumn} as target_id`,
|
||||
`${targetModel.tableName}.published_at as target_published_at`,
|
||||
`${targetModel.tableName}.document_id as target_document_id`
|
||||
)
|
||||
.leftJoin(
|
||||
targetModel.tableName,
|
||||
`${joinTableName}.${targetColumn}`,
|
||||
`${targetModel.tableName}.id`
|
||||
);
|
||||
|
||||
// Group by component instance (source)
|
||||
const bySource: Record<string, any[]> = {};
|
||||
for (const r of rows) {
|
||||
const key = String(r.source_id);
|
||||
bySource[key] = bySource[key] || [];
|
||||
bySource[key].push(r);
|
||||
}
|
||||
|
||||
// Resolve table names for our parents to detect D&P support
|
||||
const productMd = db.metadata.get(PRODUCT_UID);
|
||||
const boxMd = db.metadata.get(BOX_UID);
|
||||
const productCmpsTable = productMd?.tableName ? `${productMd.tableName}_cmps` : undefined;
|
||||
const boxCmpsTable = boxMd?.tableName ? `${boxMd.tableName}_cmps` : undefined;
|
||||
|
||||
const toDelete: number[] = [];
|
||||
|
||||
for (const [sourceId, entries] of Object.entries(bySource)) {
|
||||
if (entries.length <= 1) continue;
|
||||
|
||||
// Parent D&P check: find component row to get entity_id
|
||||
let parentSupportsDP = true;
|
||||
try {
|
||||
// Prefer mapping tables to detect parent type reliably
|
||||
let productParent = null;
|
||||
let boxParent = null;
|
||||
if (productCmpsTable) {
|
||||
productParent = await db
|
||||
.connection(productCmpsTable)
|
||||
.select('entity_id')
|
||||
.where('cmp_id', Number(sourceId))
|
||||
.first();
|
||||
}
|
||||
if (!productParent && boxCmpsTable) {
|
||||
boxParent = await db
|
||||
.connection(boxCmpsTable)
|
||||
.select('entity_id')
|
||||
.where('cmp_id', Number(sourceId))
|
||||
.first();
|
||||
}
|
||||
if (boxParent) parentSupportsDP = false;
|
||||
} catch (e) {
|
||||
// If any error, default to safe side: do not delete
|
||||
parentSupportsDP = false;
|
||||
}
|
||||
if (!parentSupportsDP) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// For each document, if both draft and published relations exist, delete the published one(s)
|
||||
const byDoc: Record<string, any[]> = {};
|
||||
for (const e of entries) {
|
||||
const key = String(e.target_document_id ?? '');
|
||||
byDoc[key] = byDoc[key] || [];
|
||||
byDoc[key].push(e);
|
||||
}
|
||||
for (const [docId, docEntries] of Object.entries(byDoc)) {
|
||||
if (!docId || docEntries.length <= 1) continue;
|
||||
const publishedEntries = docEntries.filter((e) => e.target_published_at !== null);
|
||||
const draftEntries = docEntries.filter((e) => e.target_published_at === null);
|
||||
if (publishedEntries.length > 0 && draftEntries.length > 0) {
|
||||
for (const pub of publishedEntries) toDelete.push(pub.join_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (toDelete.length > 0) {
|
||||
await db.connection(joinTableName).whereIn('id', toDelete).del();
|
||||
}
|
||||
|
||||
return toDelete.length;
|
||||
} catch (err) {
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
let strapi: Core.Strapi;
|
||||
const builder = createTestBuilder();
|
||||
|
||||
const TAG_UID = 'api::repair-tag.repair-tag';
|
||||
const PRODUCT_UID = 'api::repair-product.repair-product';
|
||||
const BOX_UID = 'api::repair-box.repair-box';
|
||||
const ARTICLE_UID = 'api::repair-article.repair-article';
|
||||
const REPAIR_COMPONENT_UID = 'default.repair-compo';
|
||||
const REPAIR_INNER_COMPONENT_UID = 'default.repair-inner';
|
||||
|
||||
const innerComponentModel = {
|
||||
collectionName: 'components_repair_inner',
|
||||
attributes: {
|
||||
rtags: {
|
||||
type: 'relation',
|
||||
relation: 'oneToMany',
|
||||
target: TAG_UID,
|
||||
},
|
||||
},
|
||||
displayName: 'repair-inner',
|
||||
};
|
||||
|
||||
const componentModel = {
|
||||
collectionName: 'components_repair_compo',
|
||||
attributes: {
|
||||
rtag: {
|
||||
type: 'relation',
|
||||
relation: 'oneToOne',
|
||||
target: TAG_UID,
|
||||
},
|
||||
rtags: {
|
||||
type: 'relation',
|
||||
relation: 'oneToMany',
|
||||
target: TAG_UID,
|
||||
},
|
||||
inner: {
|
||||
type: 'component',
|
||||
component: REPAIR_INNER_COMPONENT_UID,
|
||||
},
|
||||
},
|
||||
displayName: 'repair-compo',
|
||||
};
|
||||
|
||||
const productModel = {
|
||||
attributes: {
|
||||
name: { type: 'string' },
|
||||
rcompo: { type: 'component', component: REPAIR_COMPONENT_UID },
|
||||
},
|
||||
draftAndPublish: true,
|
||||
displayName: 'Repair Product',
|
||||
singularName: 'repair-product',
|
||||
pluralName: 'repair-products',
|
||||
};
|
||||
|
||||
const tagModel = {
|
||||
attributes: {
|
||||
name: { type: 'string' },
|
||||
},
|
||||
draftAndPublish: true,
|
||||
displayName: 'Repair Tag',
|
||||
singularName: 'repair-tag',
|
||||
pluralName: 'repair-tags',
|
||||
};
|
||||
|
||||
// Non-component content type with a unidirectional many-to-many relation to tags
|
||||
const articleModel = {
|
||||
attributes: {
|
||||
title: { type: 'string' },
|
||||
tags: {
|
||||
type: 'relation',
|
||||
relation: 'manyToMany',
|
||||
target: TAG_UID,
|
||||
},
|
||||
},
|
||||
draftAndPublish: true,
|
||||
displayName: 'Repair Article',
|
||||
singularName: 'repair-article',
|
||||
pluralName: 'repair-articles',
|
||||
};
|
||||
|
||||
// Parent without D&P that still embeds the component
|
||||
const boxModel = {
|
||||
attributes: {
|
||||
title: { type: 'string' },
|
||||
rcompo: { type: 'component', component: REPAIR_COMPONENT_UID },
|
||||
},
|
||||
draftAndPublish: false,
|
||||
displayName: 'Repair Box',
|
||||
singularName: 'repair-box',
|
||||
pluralName: 'repair-boxes',
|
||||
};
|
||||
|
||||
// Helper to locate component->rtags join table and its column names from metadata
|
||||
const getCompoTagsRelationInfo = () => {
|
||||
const componentMd: any = (strapi as any).db.metadata.get(REPAIR_COMPONENT_UID);
|
||||
if (!componentMd) {
|
||||
throw new Error('Repair component metadata not found');
|
||||
}
|
||||
const relation: any = componentMd.attributes?.rtags;
|
||||
if (!relation?.joinTable) {
|
||||
throw new Error('Repair component rtags relation not found');
|
||||
}
|
||||
const joinTableName = relation.joinTable.name;
|
||||
const sourceColumn = relation.joinTable.joinColumn.name;
|
||||
const targetColumn = relation.joinTable.inverseJoinColumn.name;
|
||||
return { joinTableName, sourceColumn, targetColumn } as const;
|
||||
};
|
||||
|
||||
// Helper to locate InnerComponent->rtags join table and its column names from metadata
|
||||
const getInnerCompoTagsRelationInfo = () => {
|
||||
const componentMd: any = (strapi as any).db.metadata.get(REPAIR_INNER_COMPONENT_UID);
|
||||
if (!componentMd) {
|
||||
throw new Error('Repair inner component metadata not found');
|
||||
}
|
||||
const relation: any = componentMd.attributes?.rtags;
|
||||
if (!relation?.joinTable) {
|
||||
throw new Error('Repair inner component rtags relation not found');
|
||||
}
|
||||
const joinTableName = relation.joinTable.name;
|
||||
const sourceColumn = relation.joinTable.joinColumn.name;
|
||||
const targetColumn = relation.joinTable.inverseJoinColumn.name;
|
||||
return { joinTableName, sourceColumn, targetColumn } as const;
|
||||
};
|
||||
|
||||
// Helper to locate Article->tags join table and its column names from metadata
|
||||
const getArticleTagsRelationInfo = () => {
|
||||
const articleMd: any = (strapi as any).db.metadata.get(ARTICLE_UID);
|
||||
if (!articleMd) {
|
||||
throw new Error('Repair article metadata not found');
|
||||
}
|
||||
const relation: any = articleMd.attributes?.tags;
|
||||
if (!relation?.joinTable) {
|
||||
throw new Error('Repair article tags relation not found');
|
||||
}
|
||||
const joinTableName = relation.joinTable.name;
|
||||
const sourceColumn = relation.joinTable.joinColumn.name;
|
||||
const targetColumn = relation.joinTable.inverseJoinColumn.name;
|
||||
return { joinTableName, sourceColumn, targetColumn } as const;
|
||||
};
|
||||
|
||||
const selectAll = async (table: string) => {
|
||||
const rows = await strapi.db.connection(table).select('*');
|
||||
return rows as any[];
|
||||
};
|
||||
|
||||
const selectBySource = async (table: string, sourceColumn: string, sourceId: number) => {
|
||||
const rows = await strapi.db.connection(table).select('*').where(sourceColumn, sourceId);
|
||||
return rows as any[];
|
||||
};
|
||||
|
||||
describe('Unidirectional join-table repair (components)', () => {
|
||||
beforeAll(async () => {
|
||||
await builder
|
||||
.addContentTypes([tagModel])
|
||||
.addComponent(innerComponentModel)
|
||||
.addComponent(componentModel)
|
||||
.addContentTypes([productModel, boxModel, articleModel])
|
||||
.build();
|
||||
|
||||
strapi = await createStrapiInstance({ logLevel: 'error' });
|
||||
|
||||
// Seed baseline tag used across tests (draft+published)
|
||||
await strapi.db
|
||||
.query(TAG_UID)
|
||||
.create({ data: { documentId: 'GTagR', name: 'GhostTagR', publishedAt: null } });
|
||||
await strapi.documents(TAG_UID).publish({ documentId: 'GTagR' });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await strapi.destroy();
|
||||
await builder.cleanup();
|
||||
});
|
||||
|
||||
it('Removes duplicate published row when both draft and published exist for one component instance', async () => {
|
||||
const { joinTableName, sourceColumn, targetColumn } = getCompoTagsRelationInfo();
|
||||
|
||||
// Get draft & published tag IDs for documentId GTagR
|
||||
const tagVersions = await strapi.db.query(TAG_UID).findMany({ where: { documentId: 'GTagR' } });
|
||||
const draftTag = tagVersions.find((t: any) => t.publishedAt === null)!;
|
||||
const publishedTag = tagVersions.find((t: any) => t.publishedAt !== null)!;
|
||||
|
||||
// Create draft product with a component relation to the draft tag
|
||||
const product = await strapi.documents(PRODUCT_UID).create({
|
||||
data: {
|
||||
name: 'Product-with-ghost',
|
||||
rcompo: { rtags: [{ documentId: 'GTagR', status: 'draft' }] },
|
||||
},
|
||||
status: 'draft',
|
||||
});
|
||||
|
||||
// There should be a single draft relation row for the component instance
|
||||
let rows = await selectAll(joinTableName);
|
||||
|
||||
expect(rows.length).toBe(1);
|
||||
const draftRow = rows[0];
|
||||
expect(draftRow[targetColumn]).toBe(draftTag.id);
|
||||
|
||||
// Insert an unintended duplicate pointing to the published tag for the same source
|
||||
const sourceId = draftRow[sourceColumn];
|
||||
const insertRow = { ...draftRow };
|
||||
delete (insertRow as any).id;
|
||||
insertRow[targetColumn] = publishedTag.id;
|
||||
await strapi.db.connection(joinTableName).insert(insertRow);
|
||||
|
||||
rows = await selectBySource(joinTableName, sourceColumn, sourceId);
|
||||
|
||||
expect(rows.length).toBe(2);
|
||||
const targetIds = rows.map((r) => r[targetColumn]).sort();
|
||||
expect(targetIds).toEqual([draftTag.id, publishedTag.id].sort());
|
||||
|
||||
const removed = await strapi.db.repair.processUnidirectionalJoinTables(testCleaner);
|
||||
|
||||
expect(removed).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Only the published duplicate should be removed; the draft relation must remain
|
||||
rows = await selectBySource(joinTableName, sourceColumn, sourceId);
|
||||
|
||||
expect(rows.length).toBe(1);
|
||||
expect(rows[0][targetColumn]).toBe(draftTag.id);
|
||||
|
||||
// Cleanup created product
|
||||
await strapi.documents(PRODUCT_UID).delete({ documentId: product.documentId });
|
||||
});
|
||||
|
||||
it('Repairs duplicates for nested component relations (inner component -> tags)', async () => {
|
||||
const { joinTableName, sourceColumn, targetColumn } = getInnerCompoTagsRelationInfo();
|
||||
|
||||
// Get draft & published tag IDs for documentId GTagR
|
||||
const tagVersions = await strapi.db.query(TAG_UID).findMany({ where: { documentId: 'GTagR' } });
|
||||
const draftTag = tagVersions.find((t: any) => t.publishedAt === null)!;
|
||||
const publishedTag = tagVersions.find((t: any) => t.publishedAt !== null)!;
|
||||
|
||||
// Create draft product with a nested component relation to the draft tag
|
||||
const product = await strapi.documents(PRODUCT_UID).create({
|
||||
data: {
|
||||
name: 'Product-nested-ghost',
|
||||
rcompo: { inner: { rtags: [{ documentId: 'GTagR', status: 'draft' }] } },
|
||||
},
|
||||
status: 'draft',
|
||||
});
|
||||
|
||||
// There should be a single draft relation row for the INNER component instance
|
||||
let rows = await selectAll(joinTableName);
|
||||
|
||||
expect(rows.length).toBe(1);
|
||||
const draftRow = rows[0];
|
||||
expect(draftRow[targetColumn]).toBe(draftTag.id);
|
||||
|
||||
// Insert an unintended duplicate pointing to the published tag for the same INNER source
|
||||
const sourceId = draftRow[sourceColumn];
|
||||
const insertRow = { ...draftRow } as any;
|
||||
delete insertRow.id;
|
||||
insertRow[targetColumn] = publishedTag.id;
|
||||
await strapi.db.connection(joinTableName).insert(insertRow);
|
||||
|
||||
rows = await selectBySource(joinTableName, sourceColumn, sourceId);
|
||||
|
||||
expect(rows.length).toBe(2);
|
||||
const targetIds = rows.map((r) => r[targetColumn]).sort();
|
||||
expect(targetIds).toEqual([draftTag.id, publishedTag.id].sort());
|
||||
|
||||
const removed = await strapi.db.repair.processUnidirectionalJoinTables(testCleaner);
|
||||
|
||||
expect(removed).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Only the published duplicate should be removed; the draft relation must remain
|
||||
rows = await selectBySource(joinTableName, sourceColumn, sourceId);
|
||||
|
||||
expect(rows.length).toBe(1);
|
||||
expect(rows[0][targetColumn]).toBe(draftTag.id);
|
||||
|
||||
// Cleanup created product
|
||||
await strapi.documents(PRODUCT_UID).delete({ documentId: product.documentId });
|
||||
});
|
||||
|
||||
it('Does not delete single relations (safety) for draft-only or published-only', async () => {
|
||||
const { joinTableName, sourceColumn, targetColumn } = getCompoTagsRelationInfo();
|
||||
|
||||
const tagVersions = await strapi.db.query(TAG_UID).findMany({ where: { documentId: 'GTagR' } });
|
||||
const draftTag = tagVersions.find((t: any) => t.publishedAt === null)!;
|
||||
const publishedTag = tagVersions.find((t: any) => t.publishedAt !== null)!;
|
||||
|
||||
// Draft-only relation: product in draft referencing draft tag
|
||||
const draftOnly = await strapi.documents(PRODUCT_UID).create({
|
||||
data: { name: 'Draft-only', rcompo: { rtags: [{ documentId: 'GTagR', status: 'draft' }] } },
|
||||
status: 'draft',
|
||||
});
|
||||
|
||||
// Published-only relation: product in draft referencing published tag by id
|
||||
const publishedOnly = await strapi.documents(PRODUCT_UID).create({
|
||||
data: { name: 'Published-only', rcompo: { rtags: [{ id: publishedTag.id }] } },
|
||||
status: 'draft',
|
||||
});
|
||||
|
||||
// Collect rows per component instance
|
||||
const rowsAll = await selectAll(joinTableName);
|
||||
|
||||
const compoIdsByName: Record<string, number> = {};
|
||||
// Find the two component instances by correlating to tag ids present
|
||||
// We find component instances having a single row pointing to draftTag or publishedTag respectively
|
||||
const bySource: Record<number, any[]> = {};
|
||||
for (const row of rowsAll) {
|
||||
const sid = row[sourceColumn];
|
||||
bySource[sid] = bySource[sid] || [];
|
||||
bySource[sid].push(row);
|
||||
}
|
||||
|
||||
const sources = Object.entries(bySource).filter(([, v]) => v.length === 1);
|
||||
expect(sources.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
const draftOnlySource = sources.find(
|
||||
([, [r]]) => r[targetColumn] === draftTag.id
|
||||
)![0] as unknown as number;
|
||||
const publishedOnlySource = sources.find(
|
||||
([, [r]]) => r[targetColumn] === publishedTag.id
|
||||
)![0] as unknown as number;
|
||||
compoIdsByName['draft'] = Number(draftOnlySource);
|
||||
compoIdsByName['published'] = Number(publishedOnlySource);
|
||||
|
||||
const removed = await strapi.db.repair.processUnidirectionalJoinTables(testCleaner);
|
||||
|
||||
expect(removed).toBeGreaterThanOrEqual(0);
|
||||
|
||||
// Ensure both single relations are untouched
|
||||
const draftRows = await selectBySource(joinTableName, sourceColumn, compoIdsByName['draft']);
|
||||
|
||||
expect(draftRows.length).toBe(1);
|
||||
expect(draftRows[0][targetColumn]).toBe(draftTag.id);
|
||||
|
||||
const publishedRows = await selectBySource(
|
||||
joinTableName,
|
||||
sourceColumn,
|
||||
compoIdsByName['published']
|
||||
);
|
||||
|
||||
expect(publishedRows.length).toBe(1);
|
||||
expect(publishedRows[0][targetColumn]).toBe(publishedTag.id);
|
||||
|
||||
// Cleanup
|
||||
await strapi.documents(PRODUCT_UID).delete({ documentId: draftOnly.documentId });
|
||||
await strapi.documents(PRODUCT_UID).delete({ documentId: publishedOnly.documentId });
|
||||
});
|
||||
|
||||
it("Does not delete when component's parent does not support D&P (safety)", async () => {
|
||||
const { joinTableName, sourceColumn, targetColumn } = getCompoTagsRelationInfo();
|
||||
|
||||
const tagVersions = await strapi.db.query(TAG_UID).findMany({ where: { documentId: 'GTagR' } });
|
||||
const draftTag = tagVersions.find((t: any) => t.publishedAt === null)!;
|
||||
const publishedTag = tagVersions.find((t: any) => t.publishedAt !== null)!;
|
||||
|
||||
// Create a Box (no D&P) with component relation to a draft tag via documents service
|
||||
const boxEntry = await strapi.documents(BOX_UID).create({
|
||||
data: {
|
||||
title: 'No-DP-Box',
|
||||
rcompo: {
|
||||
rtag: { documentId: 'GTagR', status: 'draft' },
|
||||
rtags: [{ documentId: 'GTagR', status: 'draft' }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Find the component instance id directly from the join table using the draft target
|
||||
const rowForDraft = await strapi.db
|
||||
.connection(joinTableName)
|
||||
.select('*')
|
||||
.where(targetColumn, draftTag.id)
|
||||
.first();
|
||||
|
||||
expect(rowForDraft).toBeDefined();
|
||||
const sourceId = rowForDraft[sourceColumn];
|
||||
|
||||
// Confirm at least one join row exists for this component instance
|
||||
let rows = await selectBySource(joinTableName, sourceColumn, sourceId);
|
||||
|
||||
expect(rows.length).toBeGreaterThanOrEqual(1);
|
||||
const draftRow = rowForDraft;
|
||||
|
||||
// Insert a published duplicate for the same component instance
|
||||
const duplicate = { ...draftRow };
|
||||
delete (duplicate as any).id;
|
||||
duplicate[targetColumn] = publishedTag.id;
|
||||
await strapi.db.connection(joinTableName).insert(duplicate);
|
||||
|
||||
rows = await selectBySource(joinTableName, sourceColumn, sourceId);
|
||||
|
||||
expect(rows.length).toBe(2);
|
||||
|
||||
// Run repair - should skip because parent (BOX) has no draftAndPublish
|
||||
const removed = await strapi.db.repair.processUnidirectionalJoinTables(testCleaner);
|
||||
|
||||
expect(removed).toBeGreaterThanOrEqual(0);
|
||||
|
||||
// Both rows must remain
|
||||
rows = await selectBySource(joinTableName, sourceColumn, sourceId);
|
||||
|
||||
expect(rows.length).toBe(2);
|
||||
|
||||
// Cleanup created box entry
|
||||
await strapi.documents(BOX_UID).delete({ documentId: boxEntry.documentId });
|
||||
});
|
||||
|
||||
it('Cleans duplicates across multiple component instances in a single run', async () => {
|
||||
const { joinTableName, sourceColumn, targetColumn } = getCompoTagsRelationInfo();
|
||||
|
||||
// Resolve draft and published tag ids
|
||||
const tagVersions = await strapi.db.query(TAG_UID).findMany({ where: { documentId: 'GTagR' } });
|
||||
const draftTag = tagVersions.find((t: any) => t.publishedAt === null)!;
|
||||
const publishedTag = tagVersions.find((t: any) => t.publishedAt !== null)!;
|
||||
|
||||
// Create two draft products each linking the component to the draft tag (distinct component instances)
|
||||
const prodA = await strapi.documents(PRODUCT_UID).create({
|
||||
data: { name: 'Multi-A', rcompo: { rtags: [{ documentId: 'GTagR', status: 'draft' }] } },
|
||||
status: 'draft',
|
||||
});
|
||||
const prodB = await strapi.documents(PRODUCT_UID).create({
|
||||
data: { name: 'Multi-B', rcompo: { rtags: [{ documentId: 'GTagR', status: 'draft' }] } },
|
||||
status: 'draft',
|
||||
});
|
||||
|
||||
// Identify the two component instances by selecting rows pointing to the draft tag
|
||||
const draftRowsAll = await strapi.db
|
||||
.connection(joinTableName)
|
||||
.select('*')
|
||||
.where(targetColumn, draftTag.id);
|
||||
expect(draftRowsAll.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
// Take two distinct sources
|
||||
const uniqueSources: number[] = [];
|
||||
for (const r of draftRowsAll) {
|
||||
const sid = r[sourceColumn];
|
||||
if (!uniqueSources.includes(sid)) uniqueSources.push(sid);
|
||||
if (uniqueSources.length === 2) break;
|
||||
}
|
||||
expect(uniqueSources.length).toBe(2);
|
||||
|
||||
// For each source, insert a duplicate row pointing to the published tag
|
||||
for (const srcId of uniqueSources) {
|
||||
const baseRow = draftRowsAll.find((r) => r[sourceColumn] === srcId)!;
|
||||
const dup = { ...baseRow } as any;
|
||||
delete dup.id;
|
||||
dup[targetColumn] = publishedTag.id;
|
||||
await strapi.db.connection(joinTableName).insert(dup);
|
||||
}
|
||||
|
||||
// Sanity: each selected source should now have both draft and published
|
||||
for (const srcId of uniqueSources) {
|
||||
const rows = await selectBySource(joinTableName, sourceColumn, srcId);
|
||||
const targets = rows.map((r) => r[targetColumn]);
|
||||
expect(targets).toEqual(expect.arrayContaining([draftTag.id, publishedTag.id]));
|
||||
}
|
||||
|
||||
// Run repair once, should remove the published duplicates for both sources
|
||||
const removed = await strapi.db.repair.processUnidirectionalJoinTables(testCleaner);
|
||||
expect(removed).toBeGreaterThanOrEqual(2);
|
||||
|
||||
// Verify only the draft relation remains for each source
|
||||
for (const srcId of uniqueSources) {
|
||||
const rows = await selectBySource(joinTableName, sourceColumn, srcId);
|
||||
expect(rows.length).toBe(1);
|
||||
expect(rows[0][targetColumn]).toBe(draftTag.id);
|
||||
}
|
||||
|
||||
// Cleanup products
|
||||
await strapi.documents(PRODUCT_UID).delete({ documentId: prodA.documentId });
|
||||
await strapi.documents(PRODUCT_UID).delete({ documentId: prodB.documentId });
|
||||
});
|
||||
|
||||
it('Only removes per-document published duplicates; different documents remain linked', async () => {
|
||||
const { joinTableName, sourceColumn, targetColumn } = getCompoTagsRelationInfo();
|
||||
|
||||
// Create a second tag document so we can link two different documents from the same component instance
|
||||
await strapi.db
|
||||
.query(TAG_UID)
|
||||
.create({ data: { documentId: 'GTagS', name: 'GhostTagS', publishedAt: null } });
|
||||
await strapi.documents(TAG_UID).publish({ documentId: 'GTagS' });
|
||||
|
||||
const tagRVersions = await strapi.db
|
||||
.query(TAG_UID)
|
||||
.findMany({ where: { documentId: 'GTagR' } });
|
||||
const tagSVersions = await strapi.db
|
||||
.query(TAG_UID)
|
||||
.findMany({ where: { documentId: 'GTagS' } });
|
||||
const draftR = tagRVersions.find((t: any) => t.publishedAt === null)!;
|
||||
const publishedR = tagRVersions.find((t: any) => t.publishedAt !== null)!;
|
||||
const draftS = tagSVersions.find((t: any) => t.publishedAt === null)!;
|
||||
const publishedS = tagSVersions.find((t: any) => t.publishedAt !== null)!;
|
||||
|
||||
// One product with a component instance
|
||||
const product = await strapi.documents(PRODUCT_UID).create({
|
||||
data: {
|
||||
name: 'Mixed-targets',
|
||||
rcompo: { rtags: [{ documentId: 'GTagR', status: 'draft' }] },
|
||||
},
|
||||
status: 'draft',
|
||||
});
|
||||
|
||||
// Identify the source component instance (row pointing to draftR)
|
||||
const baseRow = await strapi.db
|
||||
.connection(joinTableName)
|
||||
.select('*')
|
||||
.where(targetColumn, draftR.id)
|
||||
.first();
|
||||
expect(baseRow).toBeDefined();
|
||||
const sourceId = baseRow[sourceColumn];
|
||||
|
||||
// Insert a second row linking to another document (GTagS) using the draft version — legitimate
|
||||
const rowToS = { ...baseRow } as any;
|
||||
delete rowToS.id;
|
||||
rowToS[targetColumn] = draftS.id;
|
||||
await strapi.db.connection(joinTableName).insert(rowToS);
|
||||
|
||||
// Also add a published link for GTagS to simulate mixed states across different documents (still legitimate)
|
||||
const rowToSPublished = { ...baseRow } as any;
|
||||
delete rowToSPublished.id;
|
||||
rowToSPublished[targetColumn] = publishedS.id;
|
||||
await strapi.db.connection(joinTableName).insert(rowToSPublished);
|
||||
|
||||
// Sanity: we now have at least 3 rows for the same source (R-draft, S-draft, S-published)
|
||||
let rows = await selectBySource(joinTableName, sourceColumn, sourceId);
|
||||
expect(rows.length).toBeGreaterThanOrEqual(3);
|
||||
|
||||
// Add the duplicate for R document: publishedR to make sure only that published duplicate is removed
|
||||
const rowToRPublished = { ...baseRow } as any;
|
||||
delete rowToRPublished.id;
|
||||
rowToRPublished[targetColumn] = publishedR.id;
|
||||
await strapi.db.connection(joinTableName).insert(rowToRPublished);
|
||||
|
||||
rows = await selectBySource(joinTableName, sourceColumn, sourceId);
|
||||
// We should have at least: R-draft, R-published, S-draft, S-published
|
||||
expect(rows.length).toBeGreaterThanOrEqual(4);
|
||||
|
||||
const removed = await strapi.db.repair.processUnidirectionalJoinTables(testCleaner);
|
||||
// Only the published duplicate for document R (which has both draft+published) should be removed
|
||||
expect(removed).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Post-conditions: R-draft remains; S-draft remains; S-published is also removed (per-document rule)
|
||||
const after = await selectBySource(joinTableName, sourceColumn, sourceId);
|
||||
const targetIds = after.map((r) => r[targetColumn]);
|
||||
expect(after.length).toBe(2);
|
||||
expect(targetIds).toEqual(expect.arrayContaining([draftR.id, draftS.id]));
|
||||
expect(targetIds).not.toEqual(expect.arrayContaining([publishedR.id, publishedS.id]));
|
||||
|
||||
// Cleanup created product and secondary tag document
|
||||
await strapi.documents(PRODUCT_UID).delete({ documentId: product.documentId });
|
||||
await strapi.documents(TAG_UID).delete({ documentId: 'GTagS' });
|
||||
});
|
||||
|
||||
it('Repairs duplicates on a non-component unidirectional many-to-many relation', async () => {
|
||||
const { joinTableName, sourceColumn, targetColumn } = getArticleTagsRelationInfo();
|
||||
|
||||
// Ensure baseline tag (GTagR) exists and get its versions
|
||||
const tagVersions = await strapi.db.query(TAG_UID).findMany({ where: { documentId: 'GTagR' } });
|
||||
const draftTag = tagVersions.find((t: any) => t.publishedAt === null)!;
|
||||
const publishedTag = tagVersions.find((t: any) => t.publishedAt !== null)!;
|
||||
|
||||
// Create a draft article linking to draft tag via documents service
|
||||
const article = await strapi.documents(ARTICLE_UID).create({
|
||||
data: { title: 'Article-1', tags: [{ documentId: 'GTagR', status: 'draft' }] },
|
||||
status: 'draft',
|
||||
});
|
||||
|
||||
// Load the join row for this article->draftTag
|
||||
const initial = await strapi.db
|
||||
.connection(joinTableName)
|
||||
.select('*')
|
||||
.where(targetColumn, draftTag.id)
|
||||
.first();
|
||||
expect(initial).toBeDefined();
|
||||
const sourceId = initial[sourceColumn];
|
||||
|
||||
// Insert unintended duplicate pointing to the published tag
|
||||
const dup = { ...initial } as any;
|
||||
delete dup.id;
|
||||
dup[targetColumn] = publishedTag.id;
|
||||
await strapi.db.connection(joinTableName).insert(dup);
|
||||
|
||||
// Sanity check
|
||||
let rows = await selectBySource(joinTableName, sourceColumn, sourceId);
|
||||
expect(rows.length).toBe(2);
|
||||
|
||||
const removed = await strapi.db.repair.processUnidirectionalJoinTables(testCleaner);
|
||||
expect(removed).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Expect only the draft relation to remain
|
||||
rows = await selectBySource(joinTableName, sourceColumn, sourceId);
|
||||
expect(rows.length).toBe(1);
|
||||
expect(rows[0][targetColumn]).toBe(draftTag.id);
|
||||
|
||||
// Cleanup article
|
||||
await strapi.documents(ARTICLE_UID).delete({ documentId: article.documentId });
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user