From c0a815f36efc9992ef61443bf94e623ce45bee54 Mon Sep 17 00:00:00 2001 From: Yuri Kuznetsov Date: Wed, 9 Jun 2021 15:34:36 +0300 Subject: [PATCH] record hook --- .../RecordHooks/Team/BeforeLinkUserCheck.php | 60 +++++++ .../Espo/Controllers/ExternalAccount.php | 10 +- .../Espo/Core/Binding/DefaultBinding.php | 5 + .../Espo/Core/Controllers/RecordBase.php | 24 ++- .../Espo/Core/Di/RecordHookManagerAware.php | 37 +++++ .../Espo/Core/Di/RecordHookManagerSetter.php | 45 ++++++ application/Espo/Core/Record/Crud.php | 10 +- application/Espo/Core/Record/DeleteParams.php | 40 +++++ .../Espo/Core/Record/DeleteParamsFetcher.php | 42 +++++ .../Espo/Core/Record/Hook/CreateHook.php | 39 +++++ .../Espo/Core/Record/Hook/DeleteHook.php | 39 +++++ .../Espo/Core/Record/Hook/LinkHook.php | 37 +++++ .../Espo/Core/Record/Hook/Provider.php | 104 ++++++++++++ .../Espo/Core/Record/Hook/ReadHook.php | 39 +++++ application/Espo/Core/Record/Hook/Type.php | 45 ++++++ .../Espo/Core/Record/Hook/UnlinkHook.php | 37 +++++ .../Espo/Core/Record/Hook/UpdateHook.php | 39 +++++ application/Espo/Core/Record/HookManager.php | 148 ++++++++++++++++++ application/Espo/Core/Record/ReadParams.php | 40 +++++ .../Espo/Core/Record/ReadParamsFetcher.php | 42 +++++ application/Espo/Core/Record/Service.php | 28 +++- .../metadata/app/containerServices.json | 3 + .../Resources/metadata/recordDefs/Team.json | 5 +- application/Espo/Services/ExternalAccount.php | 6 +- application/Espo/Services/User.php | 5 +- .../Espo/Record/RestoreDeletedTest.php | 17 +- 26 files changed, 918 insertions(+), 28 deletions(-) create mode 100644 application/Espo/Classes/RecordHooks/Team/BeforeLinkUserCheck.php create mode 100644 application/Espo/Core/Di/RecordHookManagerAware.php create mode 100644 application/Espo/Core/Di/RecordHookManagerSetter.php create mode 100644 application/Espo/Core/Record/DeleteParams.php create mode 100644 application/Espo/Core/Record/DeleteParamsFetcher.php create mode 100644 application/Espo/Core/Record/Hook/CreateHook.php create mode 100644 application/Espo/Core/Record/Hook/DeleteHook.php create mode 100644 application/Espo/Core/Record/Hook/LinkHook.php create mode 100644 application/Espo/Core/Record/Hook/Provider.php create mode 100644 application/Espo/Core/Record/Hook/ReadHook.php create mode 100644 application/Espo/Core/Record/Hook/Type.php create mode 100644 application/Espo/Core/Record/Hook/UnlinkHook.php create mode 100644 application/Espo/Core/Record/Hook/UpdateHook.php create mode 100644 application/Espo/Core/Record/HookManager.php create mode 100644 application/Espo/Core/Record/ReadParams.php create mode 100644 application/Espo/Core/Record/ReadParamsFetcher.php diff --git a/application/Espo/Classes/RecordHooks/Team/BeforeLinkUserCheck.php b/application/Espo/Classes/RecordHooks/Team/BeforeLinkUserCheck.php new file mode 100644 index 0000000000..eda6109fe5 --- /dev/null +++ b/application/Espo/Classes/RecordHooks/Team/BeforeLinkUserCheck.php @@ -0,0 +1,60 @@ +processUserCheck($foreignEntity); + } + + private function processUserCheck(User $user): void + { + if ($user->isPortal()) { + throw new Forbidden("Can't add portal users to team."); + } + + if ($user->isSystem()) { + throw new Forbidden("Can't add system users to team."); + } + } +} diff --git a/application/Espo/Controllers/ExternalAccount.php b/application/Espo/Controllers/ExternalAccount.php index 42bfc8a6fe..0c5720a33f 100644 --- a/application/Espo/Controllers/ExternalAccount.php +++ b/application/Espo/Controllers/ExternalAccount.php @@ -31,11 +31,11 @@ namespace Espo\Controllers; use Espo\Core\Exceptions\Forbidden; - use Espo\Core\{ Controllers\RecordBase, Api\Request, Api\Response, + Record\ReadParams, }; use StdClass; @@ -108,7 +108,9 @@ class ExternalAccount extends RecordBase { $id = $request->getRouteParam('id'); - return $this->getRecordService()->read($id)->getValueMap(); + return $this->getRecordService() + ->read($id, ReadParams::create()) + ->getValueMap(); } public function putActionUpdate(Request $request, Response $response): StdClass @@ -117,7 +119,7 @@ class ExternalAccount extends RecordBase $data = $request->getParsedBody(); - list($integration, $userId) = explode('__', $id); + list ($integration, $userId) = explode('__', $id); if ($this->user->getId() !== $userId && !$this->user->isAdmin()) { throw new Forbidden(); @@ -143,7 +145,7 @@ class ExternalAccount extends RecordBase $id = $data->id; $code = $data->code; - list($integration, $userId) = explode('__', $id); + list ($integration, $userId) = explode('__', $id); if ($this->user->getId() != $userId && !$this->user->isAdmin()) { throw new Forbidden(); diff --git a/application/Espo/Core/Binding/DefaultBinding.php b/application/Espo/Core/Binding/DefaultBinding.php index f0d1369a15..afb650b1ba 100644 --- a/application/Espo/Core/Binding/DefaultBinding.php +++ b/application/Espo/Core/Binding/DefaultBinding.php @@ -108,6 +108,11 @@ class DefaultBinding implements BindingProcessor 'recordServiceContainer' ); + $binder->bindService( + 'Espo\\Core\\Record\\HookManager', + 'recordHookManager' + ); + $binder->bindService( 'Espo\\Core\\Utils\\HookManager', 'hookManager' diff --git a/application/Espo/Core/Controllers/RecordBase.php b/application/Espo/Core/Controllers/RecordBase.php index f4ab221b0c..27d9a7cac1 100644 --- a/application/Espo/Core/Controllers/RecordBase.php +++ b/application/Espo/Core/Controllers/RecordBase.php @@ -39,7 +39,9 @@ use Espo\Core\{ Record\ServiceContainer as RecordServiceContainer, Record\SearchParamsFetcher, Record\CreateParamsFetcher, + Record\ReadParamsFetcher, Record\UpdateParamsFetcher, + Record\DeleteParamsFetcher, Container, Acl, AclManager, @@ -76,11 +78,21 @@ class RecordBase extends Base implements Di\EntityManagerAware */ protected $createParamsFetcher; + /** + * @var ReadParamsFetcher + */ + protected $readParamsFetcher; + /** * @var UpdateParamsFetcher */ protected $updateParamsFetcher; + /** + * @var DeleteParamsFetcher + */ + protected $deleteParamsFetcher; + /** * @var RecordServiceContainer */ @@ -100,7 +112,9 @@ class RecordBase extends Base implements Di\EntityManagerAware public function __construct( SearchParamsFetcher $searchParamsFetcher, CreateParamsFetcher $createParamsFetcher, + ReadParamsFetcher $readParamsFetcher, UpdateParamsFetcher $updateParamsFetcher, + DeleteParamsFetcher $deleteParamsFetcher, RecordServiceContainer $recordServiceContainer, Config $config, User $user, @@ -113,7 +127,9 @@ class RecordBase extends Base implements Di\EntityManagerAware ) { $this->searchParamsFetcher = $searchParamsFetcher; $this->createParamsFetcher = $createParamsFetcher; + $this->readParamsFetcher = $readParamsFetcher; $this->updateParamsFetcher = $updateParamsFetcher; + $this->deleteParamsFetcher = $deleteParamsFetcher; $this->recordServiceContainer = $recordServiceContainer; $this->config = $config; $this->user = $user; @@ -153,7 +169,9 @@ class RecordBase extends Base implements Di\EntityManagerAware $id = $request->getRouteParam('id'); - $entity = $this->getRecordService()->read($id); + $params = $this->readParamsFetcher->fetch($request); + + $entity = $this->getRecordService()->read($id, $params); if (!$entity) { throw new NotFound(); @@ -239,7 +257,9 @@ class RecordBase extends Base implements Di\EntityManagerAware $id = $request->getRouteParam('id'); - $this->getRecordService()->delete($id); + $params = $this->deleteParamsFetcher->fetch($request); + + $this->getRecordService()->delete($id, $params); return true; } diff --git a/application/Espo/Core/Di/RecordHookManagerAware.php b/application/Espo/Core/Di/RecordHookManagerAware.php new file mode 100644 index 0000000000..abeee01385 --- /dev/null +++ b/application/Espo/Core/Di/RecordHookManagerAware.php @@ -0,0 +1,37 @@ +recordHookManager = $recordHookManager; + } +} diff --git a/application/Espo/Core/Record/Crud.php b/application/Espo/Core/Record/Crud.php index c761b4fae4..8e61a440b7 100644 --- a/application/Espo/Core/Record/Crud.php +++ b/application/Espo/Core/Record/Crud.php @@ -31,27 +31,27 @@ namespace Espo\Core\Record; use Espo\ORM\Entity; -use StdClass; +use stdClass; interface Crud { /** * Create a record. */ - public function create(StdClass $data, CreateParams $params): Entity; + public function create(stdClass $data, CreateParams $params): Entity; /** * Read a record. */ - public function read(string $id): Entity; + public function read(string $id, ReadParams $params): Entity; /** * Update a record. */ - public function update(string $id, StdClass $data, UpdateParams $params): Entity; + public function update(string $id, stdClass $data, UpdateParams $params): Entity; /** * Delete a record. */ - public function delete(string $id): void; + public function delete(string $id, DeleteParams $params): void; } diff --git a/application/Espo/Core/Record/DeleteParams.php b/application/Espo/Core/Record/DeleteParams.php new file mode 100644 index 0000000000..f1b43f87e3 --- /dev/null +++ b/application/Espo/Core/Record/DeleteParams.php @@ -0,0 +1,40 @@ + CreateHook::class, + Type::BEFORE_READ => ReadHook::class, + Type::BEFORE_UPDATE => UpdateHook::class, + Type::BEFORE_DELETE => DeleteHook::class, + Type::BEFORE_LINK => LinkHook::class, + Type::BEFORE_UNLINK => UnlinkHook::class, + ]; + + public function __construct(Metadata $metadata, InjectableFactory $injectableFactory) + { + $this->metadata = $metadata; + $this->injectableFactory = $injectableFactory; + } + + /** + * @return object[] + */ + public function getList(string $entityType, string $type): array + { + $key = $entityType . '_' . $type; + + if (!array_key_exists($key, $this->map)) { + $this->map[$key] = $this->loadList($entityType, $type); + } + + return $this->map[$key]; + } + + /** + * @return object[] + */ + private function loadList(string $entityType, string $type): array + { + $key = $type . 'HookClassNameList'; + + $classNameList = $this->metadata->get(['recordDefs', $entityType, $key]) ?? []; + + $interfaceName = $this->typeInterfaceMap[$type] ?? null; + + if (!$interfaceName) { + throw new RuntimeException("Unsupported record hook type '{$type}'."); + } + + $list = []; + + foreach ($classNameList as $className) { + $class = new ReflectionClass($className); + + if (!$class->implementsInterface($interfaceName)) { + throw new RuntimeException("Hook '$className' does not implement '{$interfaceName}'."); + } + + $list[] = $this->injectableFactory->create($className); + } + + return $list; + } +} diff --git a/application/Espo/Core/Record/Hook/ReadHook.php b/application/Espo/Core/Record/Hook/ReadHook.php new file mode 100644 index 0000000000..756c25db5b --- /dev/null +++ b/application/Espo/Core/Record/Hook/ReadHook.php @@ -0,0 +1,39 @@ +provider = $provider; + } + + public function processBeforeCreate(Entity $entity, CreateParams $params): void + { + foreach ($this->getBeforeCreateHookList($entity->getEntityType()) as $hook) { + $hook->process($entity, $params); + } + } + + public function processBeforeRead(Entity $entity, ReadParams $params): void + { + foreach ($this->getBeforeReadHookList($entity->getEntityType()) as $hook) { + $hook->process($entity, $params); + } + } + + public function processBeforeUpdate(Entity $entity, UpdateParams $params): void + { + foreach ($this->getBeforeUpdateHookList($entity->getEntityType()) as $hook) { + $hook->process($entity, $params); + } + } + + public function processBeforeDelete(Entity $entity, DeleteParams $params): void + { + foreach ($this->getBeforeDeleteHookList($entity->getEntityType()) as $hook) { + $hook->process($entity, $params); + } + } + + public function processBeforeLink(Entity $entity, string $link, Entity $foreignEntity): void + { + foreach ($this->getBeforeLinkHookList($entity->getEntityType()) as $hook) { + $hook->process($entity, $link, $foreignEntity); + } + } + + public function processBeforeUnlink(Entity $entity, string $link, Entity $foreignEntity): void + { + foreach ($this->getBeforeUnlinkHookList($entity->getEntityType()) as $hook) { + $hook->process($entity, $link, $foreignEntity); + } + } + + /** + * @return ReadHook[] + */ + private function getBeforeReadHookList(string $entityType): array + { + return $this->provider->getList($entityType, Type::BEFORE_READ); + } + + /** + * @return CreateHook[] + */ + private function getBeforeCreateHookList(string $entityType): array + { + return $this->provider->getList($entityType, Type::BEFORE_CREATE); + } + + /** + * @return UpdateHook[] + */ + private function getBeforeUpdateHookList(string $entityType): array + { + return $this->provider->getList($entityType, Type::BEFORE_UPDATE); + } + + /** + * @return DeleteHook[] + */ + private function getBeforeDeleteHookList(string $entityType): array + { + return $this->provider->getList($entityType, Type::BEFORE_DELETE); + } + + /** + * @return LinkHook[] + */ + private function getBeforeLinkHookList(string $entityType): array + { + return $this->provider->getList($entityType, Type::BEFORE_LINK); + } + + /** + * @return UnlinkHook[] + */ + private function getBeforeUnlinkHookList(string $entityType): array + { + return $this->provider->getList($entityType, Type::BEFORE_UNLINK); + } +} diff --git a/application/Espo/Core/Record/ReadParams.php b/application/Espo/Core/Record/ReadParams.php new file mode 100644 index 0000000000..c99e23be81 --- /dev/null +++ b/application/Espo/Core/Record/ReadParams.php @@ -0,0 +1,40 @@ +acl->check($this->entityType, AclTable::ACTION_READ)) { throw new ForbiddenSilent(); @@ -266,6 +274,8 @@ class Service implements Crud, throw new NotFoundSilent("Record {$id} does not exist."); } + $this->recordHookManager->processBeforeRead($entity, $params); + $this->processActionHistoryRecord('read', $entity); return $entity; @@ -668,12 +678,14 @@ class Service implements Crud, $this->processAssignmentCheck($entity); - $this->beforeUpdateEntity($entity, $data); - if ($this->checkForDuplicatesInUpdate && !$params->skipDuplicateCheck()) { $this->processDuplicateCheck($entity, $data); } + $this->recordHookManager->processBeforeUpdate($entity, $params); + + $this->beforeUpdateEntity($entity, $data); + $this->entityManager->saveEntity($entity); $this->afterUpdateEntity($entity, $data); @@ -692,7 +704,7 @@ class Service implements Crud, * @throws BadRequest * @throws NotFound */ - public function delete(string $id): void + public function delete(string $id, DeleteParams $params): void { if (!$this->acl->check($this->entityType, AclTable::ACTION_DELETE)) { throw new ForbiddenSilent(); @@ -712,6 +724,8 @@ class Service implements Crud, throw new ForbiddenSilent("No delete access."); } + $this->recordHookManager->processBeforeDelete($entity, $params); + $this->beforeDeleteEntity($entity); $this->getRepository()->remove($entity); @@ -1014,6 +1028,8 @@ class Service implements Crud, throw new Forbidden(); } + $this->recordHookManager->processBeforeLink($entity, $link, $foreignEntity); + $this->getRepository()->relate($entity, $link, $foreignEntity); } @@ -1082,6 +1098,8 @@ class Service implements Crud, throw new Forbidden(); } + $this->recordHookManager->processBeforeUnlink($entity, $link, $foreignEntity); + $this->getRepository()->unrelate($entity, $link, $foreignEntity); } diff --git a/application/Espo/Resources/metadata/app/containerServices.json b/application/Espo/Resources/metadata/app/containerServices.json index ce9ebf28c4..93e2076ba3 100644 --- a/application/Espo/Resources/metadata/app/containerServices.json +++ b/application/Espo/Resources/metadata/app/containerServices.json @@ -41,6 +41,9 @@ "recordServiceContainer": { "className": "Espo\\Core\\Record\\ServiceContainer" }, + "recordHookManager": { + "className": "Espo\\Core\\Record\\HookManager" + }, "templateFileManager": { "className": "Espo\\Core\\Utils\\TemplateFileManager" }, diff --git a/application/Espo/Resources/metadata/recordDefs/Team.json b/application/Espo/Resources/metadata/recordDefs/Team.json index a04d7655cd..c2fd72228f 100644 --- a/application/Espo/Resources/metadata/recordDefs/Team.json +++ b/application/Espo/Resources/metadata/recordDefs/Team.json @@ -3,5 +3,8 @@ "delete": { "allowed": true } - } + }, + "beforeLinkHookClassNameList": [ + "Espo\\Classes\\RecordHooks\\Team\\BeforeLinkUserCheck" + ] } diff --git a/application/Espo/Services/ExternalAccount.php b/application/Espo/Services/ExternalAccount.php index 5a30471748..86844bd64b 100644 --- a/application/Espo/Services/ExternalAccount.php +++ b/application/Espo/Services/ExternalAccount.php @@ -38,6 +38,8 @@ use Espo\Core\Exceptions\Error; use Espo\Core\Exceptions\NotFound; use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Record\ReadParams; + use Espo\Core\Di; use Exception; @@ -137,9 +139,9 @@ class ExternalAccount extends Record implements Di\HookManagerAware } } - public function read(string $id): Entity + public function read(string $id, ReadParams $params): Entity { - list($integration, $userId) = explode('__', $id); + list ($integration, $userId) = explode('__', $id); if ($this->getUser()->id != $userId && !$this->getUser()->isAdmin()) { throw new Forbidden(); diff --git a/application/Espo/Services/User.php b/application/Espo/Services/User.php index a242c7a3fa..3895022204 100644 --- a/application/Espo/Services/User.php +++ b/application/Espo/Services/User.php @@ -40,6 +40,7 @@ use Espo\Core\{ Password\Recovery, Record\CreateParams, Record\UpdateParams, + Record\DeleteParams, }; use Espo\ORM\Entity; @@ -705,7 +706,7 @@ class User extends Record implements $sender->send($email); } - public function delete(string $id): void + public function delete(string $id, DeleteParams $params): void { if ($id == 'system') { throw new Forbidden(); @@ -715,7 +716,7 @@ class User extends Record implements throw new Forbidden(); } - parent::delete($id); + parent::delete($id, $params); } public function afterUpdateEntity(Entity $entity, $data) diff --git a/tests/integration/Espo/Record/RestoreDeletedTest.php b/tests/integration/Espo/Record/RestoreDeletedTest.php index 4a31720b95..e1f02ff0c5 100644 --- a/tests/integration/Espo/Record/RestoreDeletedTest.php +++ b/tests/integration/Espo/Record/RestoreDeletedTest.php @@ -29,9 +29,12 @@ namespace tests\integration\Espo\Record; +use Espo\Core\Record\CreateParams; +use Espo\Core\Record\ReadParams; +use Espo\Core\Record\DeleteParams; + class RestoreDeletedTest extends \tests\integration\Core\BaseTestCase { - public function testDeleted() { $app = $this->createApplication(); @@ -40,11 +43,11 @@ class RestoreDeletedTest extends \tests\integration\Core\BaseTestCase $account = $service->create((object) [ 'name' => 'Test' - ]); + ], CreateParams::create()); - $result = $service->delete($account->id); + $result = $service->delete($account->id, DeleteParams::create()); - $account = $service->read($account->id); + $account = $service->read($account->id, ReadParams::create()); $this->assertNotNull($account); @@ -59,13 +62,13 @@ class RestoreDeletedTest extends \tests\integration\Core\BaseTestCase $account = $service->create((object) [ 'name' => 'Test' - ]); + ], CreateParams::create()); - $service->delete($account->id); + $service->delete($account->id, DeleteParams::create()); $service->restoreDeleted($account->id); - $account = $service->read($account->id); + $account = $service->read($account->id, ReadParams::create()); $this->assertNotNull($account);