cascade removal

This commit is contained in:
Yurii
2026-03-05 09:46:30 +02:00
parent 9e2029dc0a
commit 8561b1af65
16 changed files with 602 additions and 27 deletions
@@ -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);
@@ -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<Entity>
@@ -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;
}
}
@@ -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;
@@ -0,0 +1,63 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2026 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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<Restorer<Entity>> $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();
}
}
+10 -5
View File
@@ -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<Restorer<Entity>> $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<Entity> $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);
}
@@ -59,6 +59,7 @@ class RelationConverter
RelationParam::ADDITIONAL_COLUMNS,
'noJoin',
RelationParam::INDEXES,
RelationParam::CASCADE_REMOVAL,
];
/** @var string[] */
@@ -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';
}
@@ -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<string, mixed> $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<string, mixed> $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);
}
}
}
+11
View File
@@ -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;
}
}
@@ -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"
@@ -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"
@@ -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();