diff --git a/application/Espo/Controllers/EntityManager.php b/application/Espo/Controllers/EntityManager.php index 385836b97b..38a20b28f8 100644 --- a/application/Espo/Controllers/EntityManager.php +++ b/application/Espo/Controllers/EntityManager.php @@ -33,6 +33,7 @@ use Espo\Core\Exceptions\Conflict; use Espo\Core\Exceptions\Error; use Espo\Core\InjectableFactory; use Espo\Entities\User; +use Espo\ORM\Defs\Params\RelationParam; use Espo\Tools\EntityManager\EntityManager as EntityManagerTool; use Espo\Core\Api\Request; use Espo\Core\Exceptions\BadRequest; @@ -443,11 +444,15 @@ class EntityManager throw new BadRequest(); } - /** @var array{readOnly?: bool} $params */ + /** @var array{readOnly?: bool, cascadeRemocal?: bool} $params */ $params = []; - if (property_exists($rawParams, 'readOnly')) { - $params['readOnly'] = (bool) $rawParams->readOnly; + if (property_exists($rawParams, RelationParam::READ_ONLY)) { + $params[RelationParam::READ_ONLY] = (bool) $rawParams->{RelationParam::READ_ONLY}; + } + + if (property_exists($rawParams, RelationParam::CASCADE_REMOVAL)) { + $params[RelationParam::CASCADE_REMOVAL] = (bool) $rawParams->{RelationParam::CASCADE_REMOVAL}; } $this->linkManager->updateParams($entityType, $link, $params); diff --git a/application/Espo/Core/Record/Deleted/DefaultRestorer.php b/application/Espo/Core/Record/Deleted/DefaultRestorer.php index 98a1671447..2193f63cee 100644 --- a/application/Espo/Core/Record/Deleted/DefaultRestorer.php +++ b/application/Espo/Core/Record/Deleted/DefaultRestorer.php @@ -29,12 +29,18 @@ namespace Espo\Core\Record\Deleted; -use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Exceptions\Conflict; +use Espo\Core\Field\DateTime; +use Espo\Core\Name\Field; use Espo\Core\ORM\Repository\Option\SaveOption; use Espo\Core\Utils\Metadata; +use Espo\ORM\Defs\Params\RelationParam; +use Espo\ORM\Defs\RelationDefs; use Espo\ORM\Entity; use Espo\ORM\EntityManager; use Espo\ORM\Name\Attribute; +use Espo\ORM\Query\SelectBuilder; +use Espo\ORM\Repository\Util; /** * @implements Restorer @@ -44,12 +50,13 @@ class DefaultRestorer implements Restorer public function __construct( private EntityManager $entityManager, private Metadata $metadata, + private RestorerFactory $restorerFactory, ) {} public function restore(Entity $entity): void { if (!$entity->get(Attribute::DELETED)) { - throw new Forbidden("No 'deleted' attribute."); + throw new Conflict("Entity is not soft-deleted."); } $this->entityManager @@ -57,8 +64,13 @@ class DefaultRestorer implements Restorer ->run(fn () => $this->restoreInTransaction($entity)); } + /** + * @throws Conflict + */ private function restoreInTransaction(Entity $entity): void { + $modifiedAt = $this->getModifiedAt($entity); + $repository = $this->entityManager->getRDBRepository($entity->getEntityType()); $repository->restoreDeleted($entity->getId()); @@ -72,5 +84,100 @@ class DefaultRestorer implements Restorer $entity->set('deleteId', '0'); $repository->save($entity, [SaveOption::SILENT => true]); } + + if ($modifiedAt) { + $this->restoreRelatedRecords($entity, $modifiedAt); + } + } + + /** + * @throws Conflict + */ + private function restoreRelatedRecords(Entity $entity, DateTime $modifiedAt): void + { + $relations = $this->entityManager + ->getDefs() + ->getEntity($entity->getEntityType()) + ->getRelationList(); + + foreach ($relations as $relation) { + if (!$relation->getParam(RelationParam::CASCADE_REMOVAL)) { + continue; + } + + $this->restoreRelatedLink($entity, $relation, $modifiedAt); + } + } + + /** + * @throws Conflict + */ + private function restoreRelatedLink(Entity $entity, RelationDefs $relation, DateTime $modifiedAt): void + { + $foreignEntityType = $relation->tryGetForeignEntityType(); + $foreign = $relation->tryGetForeignRelationName(); + + if (!$foreignEntityType || !$foreign) { + return; + } + + $foreignType = $this->entityManager + ->getDefs() + ->tryGetEntity($foreignEntityType) + ?->tryGetRelation($foreign) + ?->getType(); + + if (!$foreignType) { + return; + } + + if (!Util::isRelationshipEligibleForCascadeRemoval($relation->getType(), $foreignType)) { + return; + } + + $link = $relation->getName(); + + $builder = SelectBuilder::create() + ->from($foreignEntityType) + ->withDeleted(); + + $collection = $this->entityManager + ->getRelation($entity, $link) + ->clone($builder->build()) + ->sth() + ->where([ + Attribute::DELETED => true, + Field::MODIFIED_AT . '>=' => $modifiedAt->toString(), + ]) + ->find(); + + foreach ($collection as $relatedEntity) { + $this->restoreRelated($relatedEntity); + } + } + + /** + * @throws Conflict + */ + private function restoreRelated(Entity $relatedEntity): void + { + if ( + !$relatedEntity->hasAttribute(Attribute::DELETED) || + !$relatedEntity->get(Attribute::DELETED) + ) { + return; + } + + $restorer = $this->restorerFactory->create($relatedEntity->getEntityType()); + + $restorer->restore($relatedEntity); + } + + + private function getModifiedAt(Entity $entity): ?DateTime + { + $modifiedAtString = $entity->get(Field::MODIFIED_AT); + + return $modifiedAtString ? DateTime::fromString($modifiedAtString) : null; } } diff --git a/application/Espo/Core/Record/Deleted/Restorer.php b/application/Espo/Core/Record/Deleted/Restorer.php index bded892b42..4d570f4d01 100644 --- a/application/Espo/Core/Record/Deleted/Restorer.php +++ b/application/Espo/Core/Record/Deleted/Restorer.php @@ -30,17 +30,15 @@ namespace Espo\Core\Record\Deleted; use Espo\Core\Exceptions\Conflict; -use Espo\Core\Exceptions\Forbidden; use Espo\ORM\Entity; /** - * @template TEntity of Entity + * @template TEntity of Entity = Entity */ interface Restorer { /** * @param TEntity $entity A deleted entity. - * @throws Forbidden * @throws Conflict */ public function restore(Entity $entity): void; diff --git a/application/Espo/Core/Record/Deleted/RestorerFactory.php b/application/Espo/Core/Record/Deleted/RestorerFactory.php new file mode 100644 index 0000000000..56db41a7f6 --- /dev/null +++ b/application/Espo/Core/Record/Deleted/RestorerFactory.php @@ -0,0 +1,63 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Record\Deleted; + +use Espo\Core\Binding\BindingContainer; +use Espo\Core\Binding\BindingContainerBuilder; +use Espo\Core\InjectableFactory; +use Espo\Core\Utils\Metadata; +use Espo\Entities\User; +use Espo\ORM\Entity; + +class RestorerFactory +{ + public function __construct( + private Metadata $metadata, + private InjectableFactory $injectableFactory, + private User $user, + ) {} + + public function create(string $entityType): Restorer + { + /** @var class-string> $restorerClassName */ + $restorerClassName = $this->metadata->get("recordDefs.$entityType.deletedRestorerClassName") ?? + DefaultRestorer::class; + + /** @var Restorer */ + return $this->injectableFactory->createWithBinding($restorerClassName, $this->createBinding()); + } + + private function createBinding(): BindingContainer + { + return BindingContainerBuilder::create() + ->bindInstance(User::class, $this->user) + ->build(); + } +} diff --git a/application/Espo/Core/Record/Service.php b/application/Espo/Core/Record/Service.php index 58f98ff326..172dbaf2bd 100644 --- a/application/Espo/Core/Record/Service.php +++ b/application/Espo/Core/Record/Service.php @@ -42,6 +42,7 @@ use Espo\Core\FieldSanitize\SanitizeManager; use Espo\Core\ORM\Defs\AttributeParam; use Espo\Core\ORM\Entity as CoreEntity; use Espo\Core\ORM\Repository\Option\RemoveOption; +use Espo\Core\Record\Deleted\RestorerFactory; use Espo\ORM\Repository\Option\SaveContext; use Espo\Core\ORM\Repository\Option\SaveOption; use Espo\Core\ORM\Type\FieldType; @@ -976,12 +977,16 @@ class Service implements Crud, throw new NotFound(); } - /** @var class-string> $restorerClassName */ - $restorerClassName = $this->metadata->get("recordDefs.$this->entityType.deletedRestorerClassName") ?? - DefaultRestorer::class; + if (!$entity->hasAttribute(Attribute::DELETED)) { + throw new Forbidden("Record type is not restorable."); + } - /** @var Restorer $restorer */ - $restorer = $this->injectableFactory->createWithBinding($restorerClassName, $this->createBinding()); + if ($entity->get(Attribute::DELETED) !== true) { + throw new Conflict("Record type is not soft-deleted."); + } + + $factory = $this->injectableFactory->createWithBinding(RestorerFactory::class, $this->createBinding()); + $restorer = $factory->create($this->entityType); $restorer->restore($entity); } diff --git a/application/Espo/Core/Utils/Database/Orm/RelationConverter.php b/application/Espo/Core/Utils/Database/Orm/RelationConverter.php index f7fc5bd70f..b4b847e207 100644 --- a/application/Espo/Core/Utils/Database/Orm/RelationConverter.php +++ b/application/Espo/Core/Utils/Database/Orm/RelationConverter.php @@ -59,6 +59,7 @@ class RelationConverter RelationParam::ADDITIONAL_COLUMNS, 'noJoin', RelationParam::INDEXES, + RelationParam::CASCADE_REMOVAL, ]; /** @var string[] */ diff --git a/application/Espo/ORM/Defs/Params/RelationParam.php b/application/Espo/ORM/Defs/Params/RelationParam.php index 71c3a03a0b..ed1ce1f320 100644 --- a/application/Espo/ORM/Defs/Params/RelationParam.php +++ b/application/Espo/ORM/Defs/Params/RelationParam.php @@ -119,4 +119,11 @@ class RelationParam * @since 9.4.0 */ public const DISABLED = 'disabled'; + + /** + * Cascade removal Only for one-to-many, one-to-one, and parent-to-children. + * + * @since 9.4.0 + */ + public const string CASCADE_REMOVAL = 'cascadeRemoval'; } diff --git a/application/Espo/ORM/Repository/RDBRepository.php b/application/Espo/ORM/Repository/RDBRepository.php index 6d0b06a8b8..bf96ce75fb 100644 --- a/application/Espo/ORM/Repository/RDBRepository.php +++ b/application/Espo/ORM/Repository/RDBRepository.php @@ -29,6 +29,7 @@ namespace Espo\ORM\Repository; +use Espo\ORM\Defs\Params\RelationParam; use Espo\ORM\Repository\Option\SaveContext; use Espo\ORM\Defs\RelationDefs; use Espo\ORM\EntityCollection; @@ -285,6 +286,7 @@ class RDBRepository implements Repository $this->processCheckEntity($entity); $this->beforeRemove($entity, $options); $this->getMapper()->delete($entity); + $this->cascadeRemoveRelated($entity, $options); $this->afterRemove($entity, $options); } @@ -847,4 +849,63 @@ class RDBRepository implements Repository $mapper->deleteFromDb($this->entityType, $id, $onlyDeleted); } + + /** + * @param array $options + */ + private function cascadeRemoveRelated(Entity $entity, array $options): void + { + $relations = $this->entityManager + ->getDefs() + ->getEntity($this->entityType) + ->getRelationList(); + + foreach ($relations as $relation) { + if (!$relation->getParam(RelationParam::CASCADE_REMOVAL)) { + continue; + } + + $this->cascadeRemoveRelation($entity, $relation, $options); + } + } + + /** + * @param array $options + */ + private function cascadeRemoveRelation(Entity $entity, RelationDefs $relation, array $options): void + { + $foreignEntityType = $relation->tryGetForeignEntityType(); + $foreign = $relation->tryGetForeignRelationName(); + + if (!$foreignEntityType || !$foreign) { + return; + } + + $foreignType = $this->entityManager + ->getDefs() + ->tryGetEntity($foreignEntityType) + ?->tryGetRelation($foreign) + ?->getType(); + + if (!$foreignType) { + return; + } + + if (!Util::isRelationshipEligibleForCascadeRemoval($relation->getType(), $foreignType)) { + return; + } + + $link = $relation->getName(); + + $collection = $this->entityManager + ->getRelation($entity, $link) + ->sth() + ->find(); + + unset($options[SaveContext::NAME]); + + foreach ($collection as $relatedEntity) { + $this->entityManager->removeEntity($relatedEntity, $options); + } + } } diff --git a/application/Espo/ORM/Repository/Util.php b/application/Espo/ORM/Repository/Util.php index df51142fb7..2f797f0e02 100644 --- a/application/Espo/ORM/Repository/Util.php +++ b/application/Espo/ORM/Repository/Util.php @@ -31,6 +31,7 @@ namespace Espo\ORM\Repository; use Espo\ORM\Entity; +use Espo\ORM\Type\RelationType; use ReflectionClass; use InvalidArgumentException; @@ -54,4 +55,14 @@ class Util return $class->getShortName(); } + + /** + * @internal + */ + public static function isRelationshipEligibleForCascadeRemoval(string $type, string $foreignType): bool + { + return in_array($type, [RelationType::HAS_CHILDREN, RelationType::HAS_ONE]) || + $type === RelationType::BELONGS_TO && $foreignType === RelationType::HAS_ONE || + $type === RelationType::HAS_MANY && $foreignType === RelationType::BELONGS_TO; + } } diff --git a/application/Espo/Resources/i18n/en_US/Admin.json b/application/Espo/Resources/i18n/en_US/Admin.json index c6245c5eb7..b46d8166ae 100644 --- a/application/Espo/Resources/i18n/en_US/Admin.json +++ b/application/Espo/Resources/i18n/en_US/Admin.json @@ -246,7 +246,8 @@ "itemsEditable": "Items Editable", "openInNewTab": "Open in new tab", "notLockable": "Not Lockable", - "protocolRequired": "Protocol Required" + "protocolRequired": "Protocol Required", + "cascadeRemoval": "Cascade Removal" }, "strings" : { "rebuildRequired": "Rebuild is required" diff --git a/application/Espo/Resources/i18n/en_US/EntityManager.json b/application/Espo/Resources/i18n/en_US/EntityManager.json index 8e7eb3fb15..7e66396ede 100644 --- a/application/Espo/Resources/i18n/en_US/EntityManager.json +++ b/application/Espo/Resources/i18n/en_US/EntityManager.json @@ -123,7 +123,8 @@ "entityType": "Base Plus - has Activities, History and Tasks panels.\n\nEvent - available in Calendar and Activities panel.", "countDisabled": "Total number won't be displayed on the list view. Can decrease loading time when the DB table is big.", "fullTextSearch": "Running rebuild is required.", - "linkParamReadOnly": "A read-only link cannot be edited via the *link* and *unlink* API requests. It won't be possible to relate and unrelate records via the relationship panel. It still possible to edit read-only links via link and link-multiple fields." + "linkParamReadOnly": "A read-only link cannot be edited via the *link* and *unlink* API requests. It won't be possible to relate and unrelate records via the relationship panel. It still possible to edit read-only links via link and link-multiple fields.", + "linkParamCascadeRemoval": "If enabled, when a parent record is removed, associated related records will be removed as well." }, "entityNameParts": { "Category": "Category" diff --git a/application/Espo/Tools/LinkManager/LinkManager.php b/application/Espo/Tools/LinkManager/LinkManager.php index 10a0028863..ee9623498f 100644 --- a/application/Espo/Tools/LinkManager/LinkManager.php +++ b/application/Espo/Tools/LinkManager/LinkManager.php @@ -38,10 +38,13 @@ use Espo\Core\Utils\Language; use Espo\Core\Utils\Metadata; use Espo\Core\Utils\Route; use Espo\Core\Utils\Util; +use Espo\Entities\Team; +use Espo\Entities\User; use Espo\ORM\Defs\Params\EntityParam; use Espo\ORM\Defs\Params\FieldParam; use Espo\ORM\Defs\Params\RelationParam; use Espo\ORM\Entity; +use Espo\ORM\Repository\Util as RepositoryUtil; use Espo\ORM\Type\RelationType; use Espo\Tools\LinkManager\Hook\HookProcessor as LinkHookProcessor; use Espo\Tools\LinkManager\Params as LinkParams; @@ -1152,20 +1155,47 @@ class LinkManager } /** - * @param array{readOnly?: bool} $params + * @param array{readOnly?: bool, cascadeRemoval?: bool} $params * @throws Error */ public function updateParams(string $entityType, string $link, array $params): void { $type = $this->metadata->get("entityDefs.$entityType.links.$link.type"); + $foreignEntityType = $this->metadata->get("entityDefs.$entityType.links.$link.entity"); + $foreign = $this->metadata->get("entityDefs.$entityType.links.$link.foreign"); + $foreignType = null; + $isObject = false; + $isSystem = false; + + if ($foreignEntityType && $foreign) { + $foreignType = $this->metadata->get("entityDefs.$foreignEntityType.links.$foreign.type"); + + $isObject = $this->metadata->get("scopes.$foreignEntityType.object"); + $isSystem = in_array($foreignEntityType, [ + User::ENTITY_TYPE, + Team::ENTITY_TYPE, + ]); + } $defs = []; if ( in_array($type, [RelationType::HAS_MANY, RelationType::HAS_CHILDREN]) && - array_key_exists('readOnly', $params) + array_key_exists(RelationParam::READ_ONLY, $params) ) { - $defs['readOnly'] = $params['readOnly']; + $defs[RelationParam::READ_ONLY] = $params[RelationParam::READ_ONLY]; + } + + if ( + $foreignEntityType && + $foreignType && + RepositoryUtil::isRelationshipEligibleForCascadeRemoval($type, $foreignType) && + array_key_exists(RelationParam::CASCADE_REMOVAL, $params) && + $isObject && + !$isSystem + ) { + // @todo Check non-system and object. + $defs[RelationParam::CASCADE_REMOVAL] = $params[RelationParam::CASCADE_REMOVAL]; } $this->metadata->set('entityDefs', $entityType, [ @@ -1184,7 +1214,8 @@ class LinkManager public function resetToDefault(string $entityType, string $link): void { $this->metadata->delete('entityDefs', $entityType, [ - "links.$link.readOnly", + "links.$link." . RelationParam::READ_ONLY, + "links.$link." . RelationParam::CASCADE_REMOVAL, ]); $this->metadata->save(); diff --git a/client/src/views/admin/link-manager/index.js b/client/src/views/admin/link-manager/index.js index e5be2fe40e..47dea6203c 100644 --- a/client/src/views/admin/link-manager/index.js +++ b/client/src/views/admin/link-manager/index.js @@ -180,7 +180,8 @@ class LinkManagerIndexView extends View { const isRemovable = defs.isCustom; - const hasEditParams = defs.type === 'hasMany' || defs.type === 'hasChildren'; + const hasEditParams = ['hasChildren', 'hasMany'].includes(defs.type) || + ['oneToOneLeft', 'oneToOneRight', 'oneToMany'].includes(type); this.linkDataList.push({ link: link, diff --git a/client/src/views/admin/link-manager/modals/edit-params.js b/client/src/views/admin/link-manager/modals/edit-params.js index 2cc2494676..76fe52b3f0 100644 --- a/client/src/views/admin/link-manager/modals/edit-params.js +++ b/client/src/views/admin/link-manager/modals/edit-params.js @@ -43,6 +43,18 @@ export default class LinkManagerEditParamsModalView extends ModalView { */ type + /** + * @private + * @type {string|null} + */ + foreignType = null + + /** + * @private + * @type {string|null} + */ + foreignEntityType + /** * @param {{ * entityType: string, @@ -60,10 +72,21 @@ export default class LinkManagerEditParamsModalView extends ModalView { this.translate(this.props.entityType, 'scopeNames') + ' · ' + this.translate(this.props.link, 'links', this.props.entityType); - /** @type {{type: string, isCustom: boolean}} */ + this.systemEntityTypeList = ['User', 'Team']; + + /** @type {{type: string, isCustom?: boolean, entity?: string, foreign?: string|null}} */ const defs = this.getMetadata().get(`entityDefs.${this.props.entityType}.links.${this.props.link}`) || {}; this.type = defs.type; + const foreignEntityType = defs.entity ?? null; + const foreign = defs.foreign; + + this.foreignEntityType = foreignEntityType; + + if (foreignEntityType && foreign) { + this.foreignType = this.getMetadata().get(`entityDefs.${foreignEntityType}.links.${foreign}.type`); + } + this.buttonList = [ { name: 'save', @@ -104,18 +127,35 @@ export default class LinkManagerEditParamsModalView extends ModalView { }), }, false + ], + [ + { + view: new BoolFieldView({ + name: 'cascadeRemoval', + labelText: this.translate('cascadeRemoval', 'fields', 'Admin'), + params: { + tooltip: 'EntityManager.linkParamCascadeRemoval', + }, + }), + }, + false ] ] } ] }); - if (!this.hasReadOnly()) { - this.recordView.hideField('readOnly'); - this.recordView.setFieldReadOnly('readOnly'); - } + this.assignView('record', this.recordView).then(() => { + if (!this.hasReadOnly()) { + this.recordView.hideField('readOnly'); + this.recordView.setFieldReadOnly('readOnly'); + } - this.assignView('record', this.recordView, '.record'); + if (!this.hasCascadeRemoval()) { + this.recordView.hideField('cascadeRemoval'); + this.recordView.setFieldReadOnly('cascadeRemoval'); + } + }); } /** @@ -126,6 +166,23 @@ export default class LinkManagerEditParamsModalView extends ModalView { return ['hasMany', 'hasChildren'].includes(this.type); } + /** + * @private + * @return {boolean} + */ + hasCascadeRemoval() { + if (!this.foreignEntityType) { + return false; + } + + const isObject = this.getMetadata().get(`scopes.${this.foreignEntityType}.object`) && + !this.systemEntityTypeList.includes(this.foreignEntityType); + + return ['hasChildren', 'hasOne'].includes(this.type) || + (this.type === 'belongsTo' && this.foreignType === 'hasOne') || + (this.type === 'hasMany' && this.foreignType === 'belongsTo'); + } + /** * @private * @return {Record} @@ -135,7 +192,8 @@ export default class LinkManagerEditParamsModalView extends ModalView { const defs = this.getMetadata().get(`entityDefs.${this.props.entityType}.links.${this.props.link}`) || {}; return { - readOnly: defs.readOnly || false, + readOnly: defs.readOnly ?? false, + cascadeRemoval: defs.cascadeRemoval ?? false, }; } @@ -172,6 +230,10 @@ export default class LinkManagerEditParamsModalView extends ModalView { params.readOnly = this.formModel.attributes.readOnly; } + if (this.hasCascadeRemoval()) { + params.cascadeRemoval = this.formModel.attributes.cascadeRemoval; + } + try { await Espo.Ajax.postRequest('EntityManager/action/updateLinkParams', { entityType: this.props.entityType, diff --git a/schema/metadata/entityDefs.json b/schema/metadata/entityDefs.json index 22bcfdf5a7..c2065070a2 100644 --- a/schema/metadata/entityDefs.json +++ b/schema/metadata/entityDefs.json @@ -1633,6 +1633,28 @@ } } } + }, + { + "if": { + "properties": { + "type": { + "anyOf": [ + {"const": "hasMany"}, + {"const": "hasChildren"}, + {"const": "hasOne"}, + {"const": "belongsTo"} + ] + } + } + }, + "then": { + "properties": { + "cascadeRemoval": { + "type": "boolean", + "description": "Cascade removal. Related records will be removed along with the parent record removal. Not allowed for many-to-many and many-to-one. As of v9.4." + } + } + } } ] }, diff --git a/tests/integration/Espo/Record/CascadeRemovalTest.php b/tests/integration/Espo/Record/CascadeRemovalTest.php new file mode 100644 index 0000000000..43829c6fcd --- /dev/null +++ b/tests/integration/Espo/Record/CascadeRemovalTest.php @@ -0,0 +1,199 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace tests\integration\Espo\Record; + +use Espo\Core\Record\ServiceContainer; +use Espo\Modules\Crm\Entities\Account; +use Espo\Modules\Crm\Entities\Contact; +use Espo\Modules\Crm\Entities\Opportunity; +use Espo\Modules\Crm\Entities\Task; +use Espo\ORM\Defs\Params\RelationParam; +use Espo\ORM\Name\Attribute; +use Espo\ORM\Query\SelectBuilder; +use tests\integration\Core\BaseTestCase; + +class CascadeRemovalTest extends BaseTestCase +{ + /** + * @noinspection PhpUnhandledExceptionInspection + */ + public function testRemovalAndRestoral(): void + { + $metadata = $this->getMetadata(); + $metadata->set('entityDefs', 'Account', [ + 'links' => [ + 'opportunities' => [ + RelationParam::CASCADE_REMOVAL => true, + ] + ] + ]); + $metadata->set('entityDefs', 'Opportunity', [ + 'links' => [ + 'tasks' => [ + RelationParam::CASCADE_REMOVAL => true, + ] + ] + ]); + $metadata->save(); + $this->getDataManager()->clearCache(); + + $this->reCreateApplication(); + + $em = $this->getEntityManager(); + + $account = $em->createEntity('Account', ['name' => 'a1']); + $contact = $em->createEntity('Contact', ['lastName' => 'c1', 'accountId' => $account->getId()]); + $opp1 = $em->createEntity('Opportunity', ['name' => 'o1', 'accountId' => $account->getId()]); + $opp2 = $em->createEntity('Opportunity', ['name' => 'o2', 'accountId' => $account->getId()]); + $opp3 = $em->createEntity('Opportunity', ['name' => 'o3']); + + $task1 = $em->createEntity('Task', [ + 'name' => 't1', + 'parentType' => 'Opportunity', + 'parentId' => $opp1->getId(), + ]); + + $em->removeEntity($account); + + $this->assertNotNull( + $em->getRDBRepository(Account::ENTITY_TYPE) + ->clone( + SelectBuilder::create() + ->from(Account::ENTITY_TYPE) + ->withDeleted() + ->build() + ) + ->where([ + Attribute::ID => $account->getId(), + Attribute::DELETED => true, + ]) + ->findOne() + ); + + $this->assertNotNull( + $em->getRDBRepository(Contact::ENTITY_TYPE) + ->where([ + Attribute::ID => $contact->getId(), + ]) + ->findOne() + ); + + $this->assertNotNull( + $em->getRDBRepository(Opportunity::ENTITY_TYPE) + ->clone( + SelectBuilder::create() + ->from(Opportunity::ENTITY_TYPE) + ->withDeleted() + ->build() + ) + ->where([ + Attribute::ID => $opp1->getId(), + Attribute::DELETED => true, + ]) + ->findOne() + ); + + $this->assertNotNull( + $em->getRDBRepository(Opportunity::ENTITY_TYPE) + ->clone( + SelectBuilder::create() + ->from(Opportunity::ENTITY_TYPE) + ->withDeleted() + ->build() + ) + ->where([ + Attribute::ID => $opp2->getId(), + Attribute::DELETED => true, + ]) + ->findOne() + ); + + $this->assertNotNull( + $em->getRDBRepository(Task::ENTITY_TYPE) + ->clone( + SelectBuilder::create() + ->from(Task::ENTITY_TYPE) + ->withDeleted() + ->build() + ) + ->where([ + Attribute::ID => $task1->getId(), + Attribute::DELETED => true, + ]) + ->findOne() + ); + + $this->assertNotNull( + $em->getRDBRepository(Opportunity::ENTITY_TYPE) + ->where([ + Attribute::ID => $opp3->getId(), + ]) + ->findOne() + ); + + // + + $service = $this->getContainer()->getByClass(ServiceContainer::class)->get(Account::ENTITY_TYPE); + + $service->restoreDeleted($account->getId()); + + $this->assertNotNull( + $em->getRDBRepository(Account::ENTITY_TYPE) + ->where([ + Attribute::ID => $account->getId(), + ]) + ->findOne() + ); + + $this->assertNotNull( + $em->getRDBRepository(Opportunity::ENTITY_TYPE) + ->where([ + Attribute::ID => $opp1->getId(), + ]) + ->findOne() + ); + + $this->assertNotNull( + $em->getRDBRepository(Opportunity::ENTITY_TYPE) + ->where([ + Attribute::ID => $opp2->getId(), + ]) + ->findOne() + ); + + $this->assertNotNull( + $em->getRDBRepository(Task::ENTITY_TYPE) + ->where([ + Attribute::ID => $task1->getId(), + ]) + ->findOne() + ); + } +}