1798 lines
55 KiB
PHP
1798 lines
55 KiB
PHP
<?php
|
||
/************************************************************************
|
||
* This file is part of EspoCRM.
|
||
*
|
||
* EspoCRM – Open Source CRM application.
|
||
* Copyright (C) 2014-2025 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;
|
||
|
||
use Espo\Core\Binding\BindingContainer;
|
||
use Espo\Core\Binding\BindingContainerBuilder;
|
||
use Espo\Core\Exceptions\Conflict;
|
||
use Espo\Core\Exceptions\BadRequest;
|
||
use Espo\Core\Exceptions\ConflictSilent;
|
||
use Espo\Core\Exceptions\Forbidden;
|
||
use Espo\Core\Exceptions\ForbiddenSilent;
|
||
use Espo\Core\Exceptions\NotFound;
|
||
use Espo\Core\Exceptions\NotFoundSilent;
|
||
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\ORM\Repository\Option\SaveContext;
|
||
use Espo\Core\ORM\Repository\Option\SaveOption;
|
||
use Espo\Core\ORM\Type\FieldType;
|
||
use Espo\Core\Record\Access\LinkCheck;
|
||
use Espo\Core\Record\ActionHistory\Action;
|
||
use Espo\Core\Record\ActionHistory\ActionLogger;
|
||
use Espo\Core\Record\ConcurrencyControl\OptimisticProcessor;
|
||
use Espo\Core\Record\Defaults\Populator as DefaultsPopulator;
|
||
use Espo\Core\Record\Defaults\PopulatorFactory as DefaultsPopulatorFactory;
|
||
use Espo\Core\Record\Deleted\DefaultRestorer;
|
||
use Espo\Core\Record\Deleted\Restorer;
|
||
use Espo\Core\Record\DynamicLogic\InputFilterProcessor;
|
||
use Espo\Core\Record\Formula\Processor as FormulaProcessor;
|
||
use Espo\Core\Record\Input\Data;
|
||
use Espo\Core\Record\Input\Filter;
|
||
use Espo\Core\Record\Input\FilterProvider;
|
||
use Espo\Core\Select\Primary\Filters\One;
|
||
use Espo\Core\Select\SelectBuilderFactory;
|
||
use Espo\Core\Utils\Json;
|
||
use Espo\Core\Acl;
|
||
use Espo\Core\Acl\Table as AclTable;
|
||
use Espo\Core\Duplicate\Finder as DuplicateFinder;
|
||
use Espo\Core\FieldProcessing\ListLoadProcessor;
|
||
use Espo\Core\FieldProcessing\Loader\Params as FieldLoaderParams;
|
||
use Espo\Core\FieldProcessing\ReadLoadProcessor;
|
||
use Espo\Core\FieldValidation\FieldValidationParams as FieldValidationParams;
|
||
use Espo\Core\Record\Collection as RecordCollection;
|
||
use Espo\Core\Record\Duplicator\EntityDuplicator;
|
||
use Espo\Core\Record\Select\ApplierClassNameListProvider;
|
||
use Espo\Core\Select\SearchParams;
|
||
use Espo\Core\Di;
|
||
use Espo\ORM\Defs\Params\FieldParam;
|
||
use Espo\ORM\Defs\Params\RelationParam;
|
||
use Espo\ORM\Entity;
|
||
use Espo\ORM\Name\Attribute;
|
||
use Espo\ORM\Repository\RDBRepository;
|
||
use Espo\ORM\Collection;
|
||
use Espo\ORM\Query\Part\WhereClause;
|
||
use Espo\ORM\Type\AttributeType;
|
||
use Espo\Tools\Stream\Service as StreamService;
|
||
use Espo\Entities\User;
|
||
|
||
use stdClass;
|
||
use InvalidArgumentException;
|
||
use LogicException;
|
||
use RuntimeException;
|
||
|
||
/**
|
||
* The layer between a controller and ORM repository. For CRUD and other operations with records.
|
||
* Access control is processed here.
|
||
*
|
||
* Extending is not recommended. Use composition with metadata > recordDefs.
|
||
*
|
||
* @template TEntity of Entity
|
||
* @implements Crud<TEntity>
|
||
*/
|
||
class Service implements Crud,
|
||
|
||
Di\ConfigAware,
|
||
Di\ServiceFactoryAware,
|
||
Di\EntityManagerAware,
|
||
Di\UserAware,
|
||
Di\MetadataAware,
|
||
Di\AclAware,
|
||
Di\InjectableFactoryAware,
|
||
Di\FieldUtilAware,
|
||
Di\FieldValidationManagerAware,
|
||
Di\RecordServiceContainerAware,
|
||
Di\AssignmentCheckerManagerAware
|
||
{
|
||
use Di\ConfigSetter;
|
||
use Di\ServiceFactorySetter;
|
||
use Di\EntityManagerSetter;
|
||
use Di\UserSetter;
|
||
use Di\MetadataSetter;
|
||
use Di\AclSetter;
|
||
use Di\InjectableFactorySetter;
|
||
use Di\FieldUtilSetter;
|
||
use Di\FieldValidationManagerSetter;
|
||
use Di\RecordServiceContainerSetter;
|
||
use Di\AssignmentCheckerManagerSetter;
|
||
|
||
protected string $entityType;
|
||
protected bool $getEntityBeforeUpdate = false;
|
||
|
||
protected bool $maxSelectTextAttributeLengthDisabled = false;
|
||
protected ?int $maxSelectTextAttributeLength = null;
|
||
|
||
private ?StreamService $streamService = null;
|
||
|
||
/**
|
||
* @var string[]
|
||
* @deprecated As of v8.2. Use recordDefs > mandatoryAttributeList.
|
||
* @todo Remove in v10.0. Fix usages.
|
||
*/
|
||
protected $mandatorySelectAttributeList = [];
|
||
|
||
private ?ListLoadProcessor $listLoadProcessor = null;
|
||
private ?DuplicateFinder $duplicateFinder = null;
|
||
private ?LinkCheck $linkCheck = null;
|
||
private ?ActionLogger $actionLogger = null;
|
||
/** @var ?DefaultsPopulator<Entity> */
|
||
private ?DefaultsPopulator $defaultsPopulator = null;
|
||
private ?HookManager $recordHookManager = null;
|
||
/** @var ?Filter[] */
|
||
private ?array $createFilterList = null;
|
||
/** @var ?Filter[] */
|
||
private ?array $updateFilterList = null;
|
||
/** @var ?Output\Filter<Entity>[] */
|
||
private ?array $outputFilterList = null;
|
||
|
||
protected const MAX_SELECT_TEXT_ATTRIBUTE_LENGTH = 10000;
|
||
|
||
public function __construct(
|
||
protected SelectBuilderFactory $selectBuilderFactory,
|
||
string $entityType = '',
|
||
) {
|
||
$this->entityType = $entityType;
|
||
|
||
$this->initEntityType();
|
||
}
|
||
|
||
/**
|
||
* @internal
|
||
*/
|
||
protected function initEntityType(): void
|
||
{}
|
||
|
||
/**
|
||
* @return RDBRepository<TEntity>
|
||
*/
|
||
protected function getRepository(): RDBRepository
|
||
{
|
||
return $this->entityManager->getRDBRepository($this->entityType);
|
||
}
|
||
|
||
/**
|
||
* Add an action-history record.
|
||
*
|
||
* @param Action::* $action
|
||
* @noinspection PhpDocSignatureInspection
|
||
*/
|
||
public function processActionHistoryRecord(string $action, Entity $entity): void
|
||
{
|
||
if (
|
||
$this->config->get('actionHistoryDisabled') ||
|
||
$this->metadata->get("recordDefs.$this->entityType.actionHistoryDisabled")
|
||
) {
|
||
return;
|
||
}
|
||
|
||
$this->getActionLogger()->log($action, $entity);
|
||
}
|
||
|
||
private function getActionLogger(): ActionLogger
|
||
{
|
||
if (!$this->actionLogger) {
|
||
$this->actionLogger = $this->injectableFactory->createResolved(ActionLogger::class);
|
||
}
|
||
|
||
return $this->actionLogger;
|
||
}
|
||
|
||
/**
|
||
* Read a record by ID. Access control check is performed.
|
||
*
|
||
* Is not supposed to be directly used in customizations.
|
||
*
|
||
* @param non-empty-string $id
|
||
* @return TEntity
|
||
* @throws NotFoundSilent If not found.
|
||
* @throws Forbidden If no read access.
|
||
* @noinspection PhpDocSignatureInspection
|
||
* @todo In v10.0, return ReadResult instead of Entity.
|
||
*/
|
||
public function read(string $id, ReadParams $params): Entity
|
||
{
|
||
if ($id === '') {
|
||
throw new InvalidArgumentException();
|
||
}
|
||
|
||
if (!$this->acl->check($this->entityType, AclTable::ACTION_READ)) {
|
||
throw new ForbiddenSilent("No read access.");
|
||
}
|
||
|
||
$entity = $this->getEntity($id);
|
||
|
||
if (!$entity) {
|
||
throw new NotFoundSilent("Record $id does not exist.");
|
||
}
|
||
|
||
$this->getRecordHookManager()->processBeforeRead($entity, $params);
|
||
$this->processActionHistoryRecord(Action::READ, $entity);
|
||
|
||
return $entity;
|
||
}
|
||
|
||
/**
|
||
* Get an entity by ID. Access control check is performed.
|
||
*
|
||
* @throws Forbidden If no read access.
|
||
* @return ?TEntity
|
||
* @noinspection PhpDocSignatureInspection
|
||
*/
|
||
public function getEntity(string $id): ?Entity
|
||
{
|
||
try {
|
||
$builder = $this->selectBuilderFactory->create()
|
||
->from($this->entityType)
|
||
->withSearchParams(
|
||
SearchParams::create()
|
||
->withSelect(['*'])
|
||
->withPrimaryFilter(One::NAME)
|
||
)
|
||
->withAdditionalApplierClassNameList(
|
||
$this->createSelectApplierClassNameListProvider()->get($this->entityType)
|
||
);
|
||
|
||
// @todo Apply access control filter. If a parameter enabled? Check compatibility.
|
||
|
||
$query = $builder
|
||
->buildQueryBuilder()
|
||
->order([])
|
||
->build();
|
||
} catch (BadRequest $e) {
|
||
throw new RuntimeException($e->getMessage());
|
||
}
|
||
|
||
$entity = $this->getRepository()
|
||
->clone($query)
|
||
->where([Attribute::ID => $id])
|
||
->findOne();
|
||
|
||
if (!$entity && $this->user->isAdmin()) {
|
||
$entity = $this->getEntityEvenDeleted($id);
|
||
}
|
||
|
||
if (!$entity) {
|
||
return null;
|
||
}
|
||
|
||
$this->loadAdditionalFields($entity);
|
||
|
||
if (!$this->acl->check($entity, AclTable::ACTION_READ)) {
|
||
throw new ForbiddenSilent("No 'read' access.");
|
||
}
|
||
|
||
/** @noinspection PhpDeprecationInspection */
|
||
$this->prepareEntityForOutput($entity);
|
||
|
||
return $entity;
|
||
}
|
||
|
||
protected function getStreamService(): StreamService
|
||
{
|
||
if (empty($this->streamService)) {
|
||
$this->streamService = $this->injectableFactory->create(StreamService::class);
|
||
}
|
||
|
||
return $this->streamService;
|
||
}
|
||
|
||
private function createReadLoadProcessor(): ReadLoadProcessor
|
||
{
|
||
return $this->injectableFactory->createWithBinding(ReadLoadProcessor::class, $this->createBinding());
|
||
}
|
||
|
||
private function getListLoadProcessor(): ListLoadProcessor
|
||
{
|
||
if (!$this->listLoadProcessor) {
|
||
$this->listLoadProcessor =
|
||
$this->injectableFactory->createWithBinding(ListLoadProcessor::class, $this->createBinding());
|
||
}
|
||
|
||
return $this->listLoadProcessor;
|
||
}
|
||
|
||
/**
|
||
* @param TEntity $entity
|
||
* @noinspection PhpDocSignatureInspection
|
||
*/
|
||
public function loadAdditionalFields(Entity $entity): void
|
||
{
|
||
$loadProcessor = $this->createReadLoadProcessor();
|
||
|
||
$loadProcessor->process($entity);
|
||
}
|
||
|
||
/**
|
||
* @param Entity $entity
|
||
*/
|
||
private function loadListAdditionalFields(Entity $entity, ?SearchParams $searchParams = null): void
|
||
{
|
||
$params = new FieldLoaderParams();
|
||
|
||
if ($searchParams && $searchParams->getSelect()) {
|
||
$params = $params->withSelect($searchParams->getSelect());
|
||
}
|
||
|
||
$loadProcessor = $this->getListLoadProcessor();
|
||
|
||
$loadProcessor->process($entity, $params);
|
||
}
|
||
|
||
/**
|
||
* Validate an entity.
|
||
*
|
||
* @param TEntity $entity An entity.
|
||
* @param stdClass $data Raw input data.
|
||
* @throws BadRequest
|
||
* @noinspection PhpDocSignatureInspection
|
||
*/
|
||
public function processValidation(Entity $entity, stdClass $data): void
|
||
{
|
||
$params = FieldValidationParams::create();
|
||
|
||
$this->fieldValidationManager->process($entity, $data, $params);
|
||
}
|
||
|
||
/**
|
||
* @param TEntity $entity
|
||
* @throws Forbidden
|
||
* @noinspection PhpDocSignatureInspection
|
||
*/
|
||
protected function processAssignmentCheck(Entity $entity): void
|
||
{
|
||
if (!$this->checkAssignment($entity)) {
|
||
throw new Forbidden("Assignment failure: assigned user or team not allowed.");
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Check whether assignment can be applied for an entity.
|
||
*
|
||
* @param TEntity $entity
|
||
* @noinspection PhpDocSignatureInspection
|
||
*/
|
||
public function checkAssignment(Entity $entity): bool
|
||
{
|
||
return $this->assignmentCheckerManager->check($this->user, $entity);
|
||
}
|
||
|
||
private function getLinkCheck(): LinkCheck
|
||
{
|
||
if (!$this->linkCheck) {
|
||
$linkCheck = $this->injectableFactory->createWithBinding(
|
||
LinkCheck::class,
|
||
BindingContainerBuilder::create()
|
||
->bindInstance(Acl::class, $this->acl)
|
||
->bindInstance(User::class, $this->user)
|
||
->build()
|
||
);
|
||
|
||
$this->linkCheck = $linkCheck;
|
||
}
|
||
|
||
return $this->linkCheck;
|
||
}
|
||
|
||
/**
|
||
* Sanitize input data.
|
||
*
|
||
* @param stdClass $data Input data.
|
||
* @since 8.1.0
|
||
*/
|
||
public function sanitizeInput(stdClass $data): void
|
||
{
|
||
$manager = $this->injectableFactory->create(SanitizeManager::class);
|
||
|
||
$manager->process($this->entityType, $data);
|
||
|
||
$this->sanitizeInputForeign($data);
|
||
}
|
||
|
||
private function sanitizeInputForeign(stdClass $data): void
|
||
{
|
||
$entityDefs = $this->entityManager->getDefs()->getEntity($this->entityType);
|
||
|
||
/** @var array<string, Entity> $map */
|
||
$map = [];
|
||
|
||
foreach ($entityDefs->getFieldList() as $fieldDefs) {
|
||
if ($fieldDefs->getType() !== FieldType::FOREIGN) {
|
||
continue;
|
||
}
|
||
|
||
$link = $fieldDefs->getParam(FieldParam::LINK);
|
||
$foreignField = $fieldDefs->getParam(FieldParam::FIELD);
|
||
|
||
if (!$link || !$foreignField) {
|
||
continue;
|
||
}
|
||
|
||
$foreignEntityType = $entityDefs->tryGetRelation($link)?->tryGetForeignEntityType();
|
||
|
||
if (!$foreignEntityType) {
|
||
continue;
|
||
}
|
||
|
||
$id = $data->{$link . 'Id'} ?? null;
|
||
|
||
if (!is_string($id)) {
|
||
continue;
|
||
}
|
||
|
||
if (!array_key_exists($link, $map)) {
|
||
$map[$link] = $this->entityManager->getEntityById($foreignEntityType, $id);
|
||
}
|
||
|
||
$foreignEntity = $map[$link] ?? null;
|
||
|
||
if (!$foreignEntity) {
|
||
continue;
|
||
}
|
||
|
||
$field = $fieldDefs->getName();
|
||
|
||
$data->$field = $foreignEntity->get($foreignField);
|
||
}
|
||
}
|
||
|
||
protected function filterInput(stdClass $data): void
|
||
{
|
||
$forbiddenAttributeList = $this->acl
|
||
->getScopeForbiddenAttributeList($this->entityType, AclTable::ACTION_EDIT);
|
||
|
||
foreach ($forbiddenAttributeList as $attribute) {
|
||
unset($data->$attribute);
|
||
}
|
||
|
||
$this->filterInputForeignAttributes($data);
|
||
}
|
||
|
||
private function filterInputForeignAttributes(stdClass $data): void
|
||
{
|
||
$entityDefs = $this->entityManager->getDefs()->tryGetEntity($this->entityType);
|
||
|
||
if (!$entityDefs) {
|
||
return;
|
||
}
|
||
|
||
foreach ($entityDefs->getAttributeList() as $attributeDefs) {
|
||
if (
|
||
$attributeDefs->getType() !== AttributeType::FOREIGN &&
|
||
!$attributeDefs->getParam(AttributeParam::IS_LINK_MULTIPLE_NAME_MAP)
|
||
) {
|
||
continue;
|
||
}
|
||
|
||
if (
|
||
// link-one
|
||
$attributeDefs->getType() === AttributeType::FOREIGN &&
|
||
$attributeDefs->getParam('attributeRole') === 'id'
|
||
) {
|
||
continue;
|
||
}
|
||
|
||
$attribute = $attributeDefs->getName();
|
||
|
||
unset($data->$attribute);
|
||
}
|
||
}
|
||
|
||
private function filterInputSystemAttributes(stdClass $data): void
|
||
{
|
||
unset($data->deleted);
|
||
unset($data->id);
|
||
unset($data->modifiedById);
|
||
unset($data->modifiedByName);
|
||
unset($data->modifiedAt);
|
||
unset($data->createdById);
|
||
unset($data->createdByName);
|
||
unset($data->createdAt);
|
||
unset($data->versionNumber);
|
||
}
|
||
|
||
public function filterCreateInput(stdClass $data): void
|
||
{
|
||
$this->filterInputSystemAttributes($data);
|
||
$this->filterInput($data);
|
||
|
||
$wrappedData = new Data($data);
|
||
|
||
foreach ($this->getCreateFilterList() as $filter) {
|
||
$filter->filter($wrappedData);
|
||
}
|
||
}
|
||
|
||
public function filterUpdateInput(stdClass $data): void
|
||
{
|
||
$this->filterInputSystemAttributes($data);
|
||
$this->filterInput($data);
|
||
$this->filterReadOnlyAfterCreate($data);
|
||
|
||
$wrappedData = new Data($data);
|
||
|
||
foreach ($this->getUpdateFilterList() as $filter) {
|
||
$filter->filter($wrappedData);
|
||
}
|
||
}
|
||
|
||
private function createFilterProvider(): FilterProvider
|
||
{
|
||
return $this->injectableFactory->createWithBinding(FilterProvider::class, $this->createBinding());
|
||
}
|
||
|
||
/**
|
||
* @return Filter[]
|
||
*/
|
||
private function getCreateFilterList(): array
|
||
{
|
||
if ($this->createFilterList === null) {
|
||
$this->createFilterList = $this->createFilterProvider()->getForCreate($this->entityType);
|
||
}
|
||
|
||
return $this->createFilterList;
|
||
}
|
||
|
||
/**
|
||
* @return Filter[]
|
||
*/
|
||
private function getUpdateFilterList(): array
|
||
{
|
||
if ($this->updateFilterList === null) {
|
||
$this->updateFilterList = $this->createFilterProvider()->getForUpdate($this->entityType);
|
||
}
|
||
|
||
return $this->updateFilterList;
|
||
}
|
||
|
||
private function filterReadOnlyAfterCreate(stdClass $data): void
|
||
{
|
||
$fieldDefsList = $this->entityManager
|
||
->getDefs()
|
||
->getEntity($this->entityType)
|
||
->getFieldList();
|
||
|
||
foreach ($fieldDefsList as $fieldDefs) {
|
||
if (!$fieldDefs->getParam('readOnlyAfterCreate')) {
|
||
continue;
|
||
}
|
||
|
||
$attributeList = $this->fieldUtil->getAttributeList($this->entityType, $fieldDefs->getName());
|
||
|
||
foreach ($attributeList as $attribute) {
|
||
unset($data->$attribute);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @param TEntity $entity
|
||
* @throws Conflict
|
||
* @noinspection PhpDocSignatureInspection
|
||
*/
|
||
private function processConcurrencyControl(Entity $entity, int $versionNumber): void
|
||
{
|
||
// @todo Use a bound interface.
|
||
$processor = $this->injectableFactory->create(OptimisticProcessor::class);
|
||
|
||
$result = $processor->process($entity, $versionNumber);
|
||
|
||
if (!$result) {
|
||
return;
|
||
}
|
||
|
||
$responseData = (object) [
|
||
'values' => $result->values,
|
||
'versionNumber' => $result->versionNumber,
|
||
];
|
||
|
||
throw ConflictSilent::createWithBody('modified', Json::encode($responseData));
|
||
}
|
||
|
||
/**
|
||
* @param TEntity $entity
|
||
* @throws Conflict
|
||
* @noinspection PhpDocSignatureInspection
|
||
*/
|
||
protected function processDuplicateCheck(Entity $entity): void
|
||
{
|
||
$duplicates = $this->findDuplicates($entity);
|
||
|
||
if (!$duplicates) {
|
||
return;
|
||
}
|
||
|
||
foreach ($duplicates as $e) {
|
||
/** @noinspection PhpDeprecationInspection */
|
||
$this->prepareEntityForOutput($e);
|
||
}
|
||
|
||
throw ConflictSilent::createWithBody('duplicate', Json::encode($duplicates->getValueMapList()));
|
||
}
|
||
|
||
/**
|
||
* @param TEntity $entity
|
||
* @noinspection PhpDocSignatureInspection
|
||
* @noinspection PhpUnusedParameterInspection
|
||
*/
|
||
public function populateDefaults(Entity $entity, stdClass $data): void
|
||
{
|
||
$this->getDefaultsPopulator()->populate($entity);
|
||
}
|
||
|
||
/**
|
||
* @return DefaultsPopulator<Entity>
|
||
*/
|
||
private function getDefaultsPopulator(): DefaultsPopulator
|
||
{
|
||
if (!$this->defaultsPopulator) {
|
||
$this->defaultsPopulator = $this->injectableFactory
|
||
->create(DefaultsPopulatorFactory::class)
|
||
->create($this->entityType);
|
||
}
|
||
|
||
return $this->defaultsPopulator;
|
||
}
|
||
|
||
/**
|
||
* Create a record.
|
||
*
|
||
* Is not supposed to be directly used in customizations.
|
||
*
|
||
* @return TEntity
|
||
* @throws BadRequest
|
||
* @throws Forbidden If no create access.
|
||
* @throws Conflict
|
||
* @noinspection PhpDocSignatureInspection
|
||
* @todo In v10.0, return CreateResult instead of Entity.
|
||
*/
|
||
public function create(stdClass $data, CreateParams $params): Entity
|
||
{
|
||
if (!$this->acl->check($this->entityType, AclTable::ACTION_CREATE)) {
|
||
throw new ForbiddenSilent("No create access.");
|
||
}
|
||
|
||
$entity = $this->getRepository()->getNew();
|
||
|
||
$this->filterCreateInput($data);
|
||
$this->sanitizeInput($data);
|
||
|
||
$entity->set($data);
|
||
$this->populateDefaults($entity, $data);
|
||
|
||
$this->getRecordHookManager()->processEarlyBeforeCreate($entity, $params);
|
||
|
||
$this->processValidation($entity, $data);
|
||
$this->checkEntityCreateAccess($entity);
|
||
$this->processAssignmentCheck($entity);
|
||
$this->getLinkCheck()->processFields($entity);
|
||
|
||
if (!$params->skipDuplicateCheck()) {
|
||
$this->processDuplicateCheck($entity);
|
||
}
|
||
|
||
$this->processApiBeforeCreateApiScript($entity, $params);
|
||
$this->getRecordHookManager()->processBeforeCreate($entity, $params);
|
||
/** @noinspection PhpDeprecationInspection */
|
||
$this->beforeCreateEntity($entity, $data);
|
||
|
||
$this->entityManager->saveEntity($entity, [
|
||
SaveOption::API => true,
|
||
SaveOption::KEEP_NEW => true,
|
||
SaveOption::DUPLICATE_SOURCE_ID => $params->getDuplicateSourceId(),
|
||
]);
|
||
|
||
$this->getRecordHookManager()->processAfterCreate($entity, $params);
|
||
|
||
$entity->setAsNotNew();
|
||
$entity->updateFetchedValues();
|
||
|
||
/** @noinspection PhpDeprecationInspection */
|
||
$this->afterCreateEntity($entity, $data);
|
||
/** @noinspection PhpDeprecationInspection */
|
||
$this->afterCreateProcessDuplicating($entity, $params);
|
||
|
||
$this->loadAdditionalFields($entity);
|
||
|
||
/** @noinspection PhpDeprecationInspection */
|
||
$this->prepareEntityForOutput($entity);
|
||
$this->processActionHistoryRecord(Action::CREATE, $entity);
|
||
|
||
return $entity;
|
||
}
|
||
|
||
/**
|
||
* Update a record.
|
||
*
|
||
* Is not supposed to be directly used in customizations.
|
||
*
|
||
* @return TEntity
|
||
* @throws NotFound If record not found.
|
||
* @throws Forbidden If no access.
|
||
* @throws Conflict
|
||
* @throws BadRequest
|
||
* @noinspection PhpDocSignatureInspection
|
||
* @todo In v10.0, return UpdateResult instead of Entity.
|
||
*/
|
||
public function update(string $id, stdClass $data, UpdateParams $params): Entity
|
||
{
|
||
if (!$this->acl->check($this->entityType, AclTable::ACTION_EDIT)) {
|
||
throw new ForbiddenSilent("No edit access.");
|
||
}
|
||
|
||
if (!$id) {
|
||
throw new BadRequest("ID is empty.");
|
||
}
|
||
|
||
$this->filterUpdateInput($data);
|
||
$this->sanitizeInput($data);
|
||
|
||
$entity = $this->getEntityBeforeUpdate ?
|
||
$this->getEntity($id) :
|
||
$this->getRepository()->getById($id);
|
||
|
||
if (!$entity) {
|
||
throw new NotFound("Record $id not found.");
|
||
}
|
||
|
||
if (!$this->getEntityBeforeUpdate) {
|
||
$this->loadAdditionalFields($entity);
|
||
}
|
||
|
||
$this->filterInputReadOnlySaved($entity, $data);
|
||
|
||
if (!$this->acl->check($entity, AclTable::ACTION_EDIT)) {
|
||
throw new ForbiddenSilent("No edit access.");
|
||
}
|
||
|
||
$entity->set($data);
|
||
|
||
if ($params->getVersionNumber() !== null) {
|
||
$this->processConcurrencyControl($entity, $params->getVersionNumber());
|
||
}
|
||
|
||
$this->getRecordHookManager()->processEarlyBeforeUpdate($entity, $params);
|
||
|
||
$this->processValidation($entity, $data);
|
||
$this->processAssignmentCheck($entity);
|
||
$this->getLinkCheck()->processFields($entity);
|
||
|
||
$checkForDuplicates =
|
||
$this->metadata->get(['recordDefs', $this->entityType, 'updateDuplicateCheck']) ?? false;
|
||
|
||
if ($checkForDuplicates && !$params->skipDuplicateCheck()) {
|
||
$this->processDuplicateCheck($entity);
|
||
}
|
||
|
||
$this->processApiBeforeUpdateApiScript($entity, $params);
|
||
$this->getRecordHookManager()->processBeforeUpdate($entity, $params);
|
||
/** @noinspection PhpDeprecationInspection */
|
||
$this->beforeUpdateEntity($entity, $data);
|
||
|
||
$context = new SaveContext();
|
||
|
||
$this->entityManager->saveEntity($entity, [
|
||
SaveOption::API => true,
|
||
SaveOption::KEEP_DIRTY => true,
|
||
SaveContext::NAME => $context,
|
||
]);
|
||
|
||
$this->getRecordHookManager()->processAfterUpdate($entity, $params);
|
||
$entity->updateFetchedValues();
|
||
|
||
/** @noinspection PhpDeprecationInspection */
|
||
$this->afterUpdateEntity($entity, $data);
|
||
|
||
if ($this->metadata->get(['recordDefs', $this->entityType, 'loadAdditionalFieldsAfterUpdate'])) {
|
||
$this->loadAdditionalFields($entity);
|
||
}
|
||
|
||
/** @noinspection PhpDeprecationInspection */
|
||
$this->prepareEntityForOutput($entity);
|
||
$this->processActionHistoryRecord(Action::UPDATE, $entity);
|
||
|
||
if ($context->isLinkUpdated() && $params->getContext()) {
|
||
$params->getContext()->linkUpdated = true;
|
||
}
|
||
|
||
return $entity;
|
||
}
|
||
|
||
/**
|
||
* Delete a record.
|
||
*
|
||
* Is not supposed to be directly used in customizations.
|
||
*
|
||
* @throws Forbidden
|
||
* @throws BadRequest
|
||
* @throws NotFound
|
||
* @throws Conflict
|
||
*/
|
||
public function delete(string $id, DeleteParams $params): void
|
||
{
|
||
if (!$this->acl->check($this->entityType, AclTable::ACTION_DELETE)) {
|
||
throw new ForbiddenSilent("No delete access.");
|
||
}
|
||
|
||
if (!$id) {
|
||
throw new BadRequest("ID is empty.");
|
||
}
|
||
|
||
$entity = $this->getRepository()->getById($id);
|
||
|
||
if (!$entity) {
|
||
throw new NotFound("Record $id not found.");
|
||
}
|
||
|
||
if (!$this->acl->check($entity, AclTable::ACTION_DELETE)) {
|
||
throw new ForbiddenSilent("No delete access.");
|
||
}
|
||
|
||
$this->getRecordHookManager()->processBeforeDelete($entity, $params);
|
||
/** @noinspection PhpDeprecationInspection */
|
||
$this->beforeDeleteEntity($entity);
|
||
|
||
$this->getRepository()->remove($entity, [RemoveOption::API => true]);
|
||
|
||
/** @noinspection PhpDeprecationInspection */
|
||
$this->afterDeleteEntity($entity);
|
||
$this->getRecordHookManager()->processAfterDelete($entity, $params);
|
||
$this->processActionHistoryRecord(Action::DELETE, $entity);
|
||
}
|
||
|
||
/**
|
||
* Find records.
|
||
*
|
||
* @return RecordCollection<TEntity>
|
||
* @throws Forbidden
|
||
* @throws BadRequest
|
||
*/
|
||
public function find(SearchParams $searchParams, ?FindParams $params = null): RecordCollection
|
||
{
|
||
if (!$this->acl->check($this->entityType, AclTable::ACTION_READ)) {
|
||
throw new ForbiddenSilent("No read access.");
|
||
}
|
||
|
||
if (!$params) {
|
||
$params = FindParams::create();
|
||
}
|
||
|
||
$disableCount = $params->noTotal() ||
|
||
$this->metadata->get(['entityDefs', $this->entityType, 'collection', 'countDisabled']);
|
||
|
||
$maxSize = $searchParams->getMaxSize();
|
||
|
||
if ($disableCount && $maxSize) {
|
||
$searchParams = $searchParams->withMaxSize($maxSize + 1);
|
||
}
|
||
|
||
$preparedSearchParams = $this->prepareSearchParams($searchParams);
|
||
|
||
try {
|
||
$query = $this->selectBuilderFactory->create()
|
||
->from($this->entityType)
|
||
->withStrictAccessControl()
|
||
->withSearchParams($preparedSearchParams)
|
||
->withAdditionalApplierClassNameList(
|
||
$this->createSelectApplierClassNameListProvider()->get($this->entityType)
|
||
)
|
||
->build();
|
||
} catch (Forbidden $e) {
|
||
throw new BadRequest($e->getMessage(), 400, $e);
|
||
}
|
||
|
||
$collection = $this->getRepository()
|
||
->clone($query)
|
||
->find();
|
||
|
||
foreach ($collection as $entity) {
|
||
/** @noinspection PhpDeprecationInspection */
|
||
$this->loadListAdditionalFields($entity, $preparedSearchParams);
|
||
|
||
/** @noinspection PhpDeprecationInspection */
|
||
$this->prepareEntityForOutput($entity);
|
||
}
|
||
|
||
if ($disableCount) {
|
||
return RecordCollection::createNoCount($collection, $maxSize);
|
||
}
|
||
|
||
$total = $this->getRepository()
|
||
->clone($query)
|
||
->count();
|
||
|
||
return RecordCollection::create($collection, $total);
|
||
}
|
||
|
||
private function createSelectApplierClassNameListProvider(): ApplierClassNameListProvider
|
||
{
|
||
return $this->injectableFactory->create(ApplierClassNameListProvider::class);
|
||
}
|
||
|
||
/**
|
||
* @return TEntity|null
|
||
* @noinspection PhpDocSignatureInspection
|
||
*/
|
||
private function getEntityEvenDeleted(string $id): ?Entity
|
||
{
|
||
$query = $this->entityManager
|
||
->getQueryBuilder()
|
||
->select()
|
||
->from($this->entityType)
|
||
->where([
|
||
Attribute::ID => $id,
|
||
])
|
||
->withDeleted()
|
||
->build();
|
||
|
||
return $this->getRepository()
|
||
->clone($query)
|
||
->findOne();
|
||
}
|
||
|
||
/**
|
||
* Restore a deleted record.
|
||
*
|
||
* @throws NotFound If not found.
|
||
* @throws Forbidden If no access.
|
||
* @throws Conflict If a conflict occurred.
|
||
*/
|
||
public function restoreDeleted(string $id): void
|
||
{
|
||
if (!$this->user->isAdmin()) {
|
||
throw new Forbidden("Only admin can restore.");
|
||
}
|
||
|
||
$entity = $this->getEntityEvenDeleted($id);
|
||
|
||
if (!$entity) {
|
||
throw new NotFound();
|
||
}
|
||
|
||
/** @var class-string<Restorer<Entity>> $restorerClassName */
|
||
$restorerClassName = $this->metadata->get("recordDefs.$this->entityType.deletedRestorerClassName") ??
|
||
DefaultRestorer::class;
|
||
|
||
/** @var Restorer<Entity> $restorer */
|
||
$restorer = $this->injectableFactory->createWithBinding($restorerClassName, $this->createBinding());
|
||
|
||
$restorer->restore($entity);
|
||
}
|
||
|
||
public function getMaxSelectTextAttributeLength(): ?int
|
||
{
|
||
if ($this->maxSelectTextAttributeLengthDisabled) {
|
||
return null;
|
||
}
|
||
|
||
if ($this->maxSelectTextAttributeLength) {
|
||
return $this->maxSelectTextAttributeLength;
|
||
}
|
||
|
||
return $this->config->get('maxSelectTextAttributeLengthForList') ??
|
||
self::MAX_SELECT_TEXT_ATTRIBUTE_LENGTH;
|
||
}
|
||
|
||
/**
|
||
* Find linked records.
|
||
*
|
||
* @param non-empty-string $link
|
||
* @return RecordCollection<Entity>
|
||
* @throws NotFound If a record not found.
|
||
* @throws Forbidden If no access.
|
||
* @throws BadRequest
|
||
*/
|
||
public function findLinked(string $id, string $link, SearchParams $searchParams): RecordCollection
|
||
{
|
||
if ($link === '') {
|
||
throw new InvalidArgumentException();
|
||
}
|
||
|
||
if (!$this->acl->check($this->entityType, AclTable::ACTION_READ)) {
|
||
throw new ForbiddenSilent("No access.");
|
||
}
|
||
|
||
$entity = $this->getRepository()->getById($id);
|
||
|
||
if (!$entity) {
|
||
throw new NotFound();
|
||
}
|
||
|
||
if (!$this->acl->check($entity, AclTable::ACTION_READ)) {
|
||
throw new ForbiddenSilent("No read access.");
|
||
}
|
||
|
||
$entityDefs = $this->entityManager
|
||
->getDefs()
|
||
->getEntity($this->entityType);
|
||
|
||
if (!$entityDefs->hasRelation($link)) {
|
||
throw new NotFound("Link does not exist.");
|
||
}
|
||
|
||
$this->processForbiddenLinkReadCheck($link);
|
||
|
||
$foreignEntityType = $entityDefs
|
||
->getRelation($link)
|
||
->getForeignEntityType();
|
||
|
||
$skipAcl = $this->metadata
|
||
->get("recordDefs.$this->entityType.relationships.$link.selectAccessControlDisabled") ?? false;
|
||
|
||
if (!$skipAcl && !$this->acl->check($foreignEntityType, AclTable::ACTION_READ)) {
|
||
throw new Forbidden();
|
||
}
|
||
|
||
$recordService = $this->recordServiceContainer->get($foreignEntityType);
|
||
|
||
$disableCount = $this->metadata
|
||
->get("recordDefs.$this->entityType.relationships.$link.countDisabled") ?? false;
|
||
|
||
$maxSize = $searchParams->getMaxSize();
|
||
|
||
if ($disableCount && $maxSize) {
|
||
$searchParams = $searchParams->withMaxSize($maxSize + 1);
|
||
}
|
||
|
||
$preparedSearchParams = $this->prepareLinkSearchParams(
|
||
$recordService->prepareSearchParams($searchParams),
|
||
$link
|
||
);
|
||
|
||
$selectBuilder = $this->selectBuilderFactory->create();
|
||
|
||
$selectBuilder
|
||
->from($foreignEntityType)
|
||
->withSearchParams($preparedSearchParams)
|
||
->withAdditionalApplierClassNameList(
|
||
$this->createSelectApplierClassNameListProvider()->get($foreignEntityType)
|
||
);
|
||
|
||
if (!$skipAcl) {
|
||
$selectBuilder->withStrictAccessControl();
|
||
} else {
|
||
$selectBuilder->withComplexExpressionsForbidden();
|
||
$selectBuilder->withWherePermissionCheck();
|
||
}
|
||
|
||
try {
|
||
$query = $selectBuilder->build();
|
||
} catch (Forbidden $e) {
|
||
throw new BadRequest($e->getMessage(), 400, $e);
|
||
}
|
||
|
||
$collection = $this->entityManager
|
||
->getRDBRepository($this->entityType)
|
||
->getRelation($entity, $link)
|
||
->clone($query)
|
||
->find();
|
||
|
||
foreach ($collection as $itemEntity) {
|
||
/** @noinspection PhpDeprecationInspection */
|
||
$this->loadListAdditionalFields($itemEntity, $preparedSearchParams);
|
||
|
||
/** @noinspection PhpDeprecationInspection */
|
||
$recordService->prepareEntityForOutput($itemEntity);
|
||
}
|
||
|
||
if ($disableCount) {
|
||
return RecordCollection::createNoCount($collection, $maxSize);
|
||
}
|
||
|
||
$total = $this->entityManager
|
||
->getRDBRepository($this->entityType)
|
||
->getRelation($entity, $link)
|
||
->clone($query)
|
||
->count();
|
||
|
||
return RecordCollection::create($collection, $total);
|
||
}
|
||
|
||
/**
|
||
* Link records.
|
||
*
|
||
* @throws BadRequest
|
||
* @throws Forbidden
|
||
* @throws NotFound
|
||
*/
|
||
public function link(string $id, string $link, string $foreignId): void
|
||
{
|
||
if (!$this->acl->check($this->entityType)) {
|
||
throw new Forbidden();
|
||
}
|
||
|
||
if (empty($id) || empty($link) || empty($foreignId)) {
|
||
throw new BadRequest();
|
||
}
|
||
|
||
$this->processForbiddenLinkEditCheck($link);
|
||
|
||
$entity = $this->getRepository()->getById($id);
|
||
|
||
if (!$entity) {
|
||
throw new NotFound();
|
||
}
|
||
|
||
if (!$entity instanceof CoreEntity) {
|
||
throw new LogicException("Only core entities are supported.");
|
||
}
|
||
|
||
$this->getLinkCheck()->processLink($entity, $link);
|
||
|
||
$foreignEntityType = $entity->getRelationParam($link, RelationParam::ENTITY);
|
||
|
||
if (!$foreignEntityType) {
|
||
throw new NotFound("Entity $this->entityType does not have link $link.");
|
||
}
|
||
|
||
$foreignEntity = $this->entityManager->getEntityById($foreignEntityType, $foreignId);
|
||
|
||
if (!$foreignEntity) {
|
||
throw new NotFound();
|
||
}
|
||
|
||
$this->getLinkCheck()->processLinkForeign($entity, $link, $foreignEntity);
|
||
|
||
$this->getRecordHookManager()->processBeforeLink($entity, $link, $foreignEntity);
|
||
|
||
$this->getRepository()
|
||
->getRelation($entity, $link)
|
||
->relate($foreignEntity, null, [SaveOption::API => true]);
|
||
|
||
$this->getRecordHookManager()->processAfterLink($entity, $link, $foreignEntity);
|
||
}
|
||
|
||
/**
|
||
* Unlink records.
|
||
*
|
||
* @throws BadRequest
|
||
* @throws Forbidden
|
||
* @throws NotFound
|
||
*/
|
||
public function unlink(string $id, string $link, string $foreignId): void
|
||
{
|
||
if (!$this->acl->check($this->entityType)) {
|
||
throw new Forbidden();
|
||
}
|
||
|
||
if (empty($id) || empty($link) || empty($foreignId)) {
|
||
throw new BadRequest();
|
||
}
|
||
|
||
$this->processForbiddenLinkEditCheck($link);
|
||
|
||
$entity = $this->getRepository()->getById($id);
|
||
|
||
if (!$entity) {
|
||
throw new NotFound();
|
||
}
|
||
|
||
if (!$entity instanceof CoreEntity) {
|
||
throw new LogicException("Only core entities are supported.");
|
||
}
|
||
|
||
$this->getLinkCheck()->processUnlink($entity, $link);
|
||
|
||
$foreignEntityType = $entity->getRelationParam($link, RelationParam::ENTITY);
|
||
|
||
if (!$foreignEntityType) {
|
||
throw new NotFound("Entity $this->entityType does not have link $link.");
|
||
}
|
||
|
||
$foreignEntity = $this->entityManager->getEntityById($foreignEntityType, $foreignId);
|
||
|
||
if (!$foreignEntity) {
|
||
throw new NotFound();
|
||
}
|
||
|
||
$this->getLinkCheck()->processUnlinkForeign($entity, $link, $foreignEntity);
|
||
|
||
$this->getRecordHookManager()->processBeforeUnlink($entity, $link, $foreignEntity);
|
||
|
||
$this->getRepository()
|
||
->getRelation($entity, $link)
|
||
->unrelate($foreignEntity, [SaveOption::API => true]);
|
||
|
||
$this->getRecordHookManager()->processAfterUnlink($entity, $link, $foreignEntity);
|
||
}
|
||
|
||
|
||
/**
|
||
* @throws BadRequest
|
||
* @throws Forbidden
|
||
* @throws NotFound
|
||
*/
|
||
public function massLink(string $id, string $link, SearchParams $searchParams): bool
|
||
{
|
||
if (!$this->acl->check($this->entityType, AclTable::ACTION_EDIT)) {
|
||
throw new Forbidden();
|
||
}
|
||
|
||
if (!$this->metadata->get("recordDefs.$this->entityType.relationships.$link.massLink")) {
|
||
throw new Forbidden("Mass link is not allowed.");
|
||
}
|
||
|
||
$this->processForbiddenLinkEditCheck($link);
|
||
|
||
$entity = $this->getRepository()->getById($id);
|
||
|
||
if (!$entity) {
|
||
throw new NotFound();
|
||
}
|
||
|
||
$this->getLinkCheck()->processLink($entity, $link);
|
||
|
||
// Not used link-check deliberately. Only edit access.
|
||
if (!$this->acl->check($entity, AclTable::ACTION_EDIT)) {
|
||
throw new Forbidden();
|
||
}
|
||
|
||
if (!$entity instanceof CoreEntity) {
|
||
throw new LogicException("Only core entities are supported.");
|
||
}
|
||
|
||
$foreignEntityType = $entity->getRelationParam($link, RelationParam::ENTITY);
|
||
|
||
if (!$foreignEntityType) {
|
||
throw new LogicException("Link '$link' has no 'entity'.");
|
||
}
|
||
|
||
$accessActionRequired = $this->metadata
|
||
->get("recordDefs.$this->entityType.relationships.$link.linkRequiredForeignAccess") ??
|
||
AclTable::ACTION_EDIT;
|
||
|
||
if (!$this->acl->check($foreignEntityType, $accessActionRequired)) {
|
||
throw new Forbidden();
|
||
}
|
||
|
||
$query = $this->selectBuilderFactory->create()
|
||
->from($foreignEntityType)
|
||
->withStrictAccessControl()
|
||
->withSearchParams($searchParams->withSelect(null))
|
||
->build();
|
||
|
||
if ($this->acl->getLevel($foreignEntityType, $accessActionRequired) === AclTable::LEVEL_ALL) {
|
||
$this->getRepository()
|
||
->getRelation($entity, $link)
|
||
->massRelate($query, [SaveOption::API => true]);
|
||
|
||
return true;
|
||
}
|
||
|
||
// @todo Apply access control filter if $accessActionRequired === 'read'. For better performance.
|
||
|
||
$countRelated = 0;
|
||
|
||
$foreignCollection = $this->entityManager
|
||
->getRDBRepository($foreignEntityType)
|
||
->clone($query)
|
||
->sth()
|
||
->find();
|
||
|
||
foreach ($foreignCollection as $foreignEntity) {
|
||
if (!$this->acl->check($foreignEntity, $accessActionRequired)) {
|
||
continue;
|
||
}
|
||
|
||
$this->getRepository()
|
||
->getRelation($entity, $link)
|
||
->relate($foreignEntity, null, [SaveOption::API => true]);
|
||
|
||
$countRelated++;
|
||
}
|
||
|
||
if ($countRelated) {
|
||
return true;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* @throws Forbidden
|
||
*/
|
||
protected function processForbiddenLinkReadCheck(string $link): void
|
||
{
|
||
$forbiddenLinkList = $this->acl
|
||
->getScopeForbiddenLinkList($this->entityType);
|
||
|
||
if (in_array($link, $forbiddenLinkList)) {
|
||
throw new Forbidden();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @throws Forbidden
|
||
*/
|
||
protected function processForbiddenLinkEditCheck(string $link): void
|
||
{
|
||
$type = $this->entityManager
|
||
->getDefs()
|
||
->getEntity($this->entityType)
|
||
->tryGetRelation($link)
|
||
?->getType();
|
||
|
||
if (
|
||
$type &&
|
||
!in_array($type, [
|
||
Entity::MANY_MANY,
|
||
Entity::HAS_MANY,
|
||
Entity::HAS_CHILDREN,
|
||
])
|
||
) {
|
||
throw new Forbidden("Only manyMany, hasMany & hasChildren relations are allowed.");
|
||
}
|
||
|
||
$forbiddenLinkList = $this->acl->getScopeForbiddenLinkList($this->entityType, AclTable::ACTION_EDIT);
|
||
|
||
if (in_array($link, $forbiddenLinkList)) {
|
||
throw new Forbidden();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Follow a record.
|
||
*
|
||
* @param string $id A record ID.
|
||
* @param string|null $userId A user ID. If not specified then a current user will be used.
|
||
*
|
||
* @throws NotFoundSilent
|
||
* @throws Forbidden
|
||
*/
|
||
public function follow(string $id, ?string $userId = null): void
|
||
{
|
||
if (!$this->acl->check($this->entityType, AclTable::ACTION_STREAM)) {
|
||
throw new Forbidden();
|
||
}
|
||
|
||
$entity = $this->getRepository()->getById($id);
|
||
|
||
if (!$entity) {
|
||
throw new NotFoundSilent();
|
||
}
|
||
|
||
if (!$this->acl->check($entity, AclTable::ACTION_STREAM)) {
|
||
throw new Forbidden();
|
||
}
|
||
|
||
if (empty($userId)) {
|
||
$userId = $this->user->getId();
|
||
}
|
||
|
||
$this->getStreamService()->followEntity($entity, $userId);
|
||
}
|
||
|
||
/**
|
||
* Unfollow a record.
|
||
*
|
||
* @param string $id A record ID.
|
||
* @param string|null $userId A user ID. If not specified then a current user will be used.
|
||
*
|
||
* @throws NotFoundSilent
|
||
*/
|
||
public function unfollow(string $id, ?string $userId = null): void
|
||
{
|
||
$entity = $this->getRepository()->getById($id);
|
||
|
||
if (!$entity) {
|
||
throw new NotFoundSilent();
|
||
}
|
||
|
||
if (empty($userId)) {
|
||
$userId = $this->user->getId();
|
||
}
|
||
|
||
$this->getStreamService()->unfollowEntity($entity, $userId);
|
||
}
|
||
|
||
private function getDuplicateFinder(): DuplicateFinder
|
||
{
|
||
if (!$this->duplicateFinder) {
|
||
$this->duplicateFinder = $this->injectableFactory->create(DuplicateFinder::class);
|
||
}
|
||
|
||
return $this->duplicateFinder;
|
||
}
|
||
|
||
/**
|
||
* Check whether an entity has a duplicate.
|
||
*
|
||
* @param TEntity $entity
|
||
* @noinspection PhpDocSignatureInspection
|
||
*/
|
||
public function checkIsDuplicate(Entity $entity): bool
|
||
{
|
||
$finder = $this->getDuplicateFinder();
|
||
|
||
// For backward compatibility.
|
||
if (method_exists($this, 'getDuplicateWhereClause')) {
|
||
$whereClause = $this->getDuplicateWhereClause($entity, (object) []);
|
||
|
||
if (!$whereClause) {
|
||
return false;
|
||
}
|
||
|
||
return $finder->checkByWhere($entity, WhereClause::fromRaw($whereClause));
|
||
}
|
||
|
||
return $finder->check($entity);
|
||
}
|
||
|
||
/**
|
||
* Find duplicates for an entity.
|
||
*
|
||
* @return ?Collection<TEntity>
|
||
*/
|
||
public function findDuplicates(Entity $entity): ?Collection
|
||
{
|
||
$finder = $this->getDuplicateFinder();
|
||
|
||
// For backward compatibility.
|
||
if (method_exists($this, 'getDuplicateWhereClause')) {
|
||
$whereClause = $this->getDuplicateWhereClause($entity, (object) []);
|
||
|
||
if (!$whereClause) {
|
||
return null;
|
||
}
|
||
|
||
/** @var ?Collection<TEntity> */
|
||
return $finder->findByWhere($entity, WhereClause::fromRaw($whereClause));
|
||
}
|
||
|
||
/** @var ?Collection<TEntity> */
|
||
return $finder->find($entity);
|
||
}
|
||
|
||
/**
|
||
* Prepare an entity for output. Clears not allowed attributes.
|
||
*
|
||
* Do not extend. Prefer metadata recordDefs > outputFilterClassNameList.
|
||
*
|
||
* @param TEntity $entity
|
||
* @noinspection PhpDocSignatureInspection
|
||
*/
|
||
public function prepareEntityForOutput(Entity $entity): void
|
||
{
|
||
$forbiddenAttributeList = $this->acl->getScopeForbiddenAttributeList($entity->getEntityType());
|
||
|
||
foreach ($forbiddenAttributeList as $attribute) {
|
||
$entity->clear($attribute);
|
||
}
|
||
|
||
foreach ($this->getOutputFilterList() as $filter) {
|
||
$filter->filter($entity);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @return Output\Filter<Entity>[]
|
||
*/
|
||
private function getOutputFilterList(): array
|
||
{
|
||
if ($this->outputFilterList === null) {
|
||
$this->outputFilterList =
|
||
$this->injectableFactory->create(Output\FilterProvider::class)->get($this->entityType);
|
||
}
|
||
|
||
return $this->outputFilterList;
|
||
}
|
||
|
||
private function createEntityDuplicator(): EntityDuplicator
|
||
{
|
||
return $this->injectableFactory->create(EntityDuplicator::class);
|
||
}
|
||
|
||
/**
|
||
* @throws BadRequest
|
||
* @throws Forbidden
|
||
* @throws ForbiddenSilent
|
||
* @throws NotFound
|
||
*/
|
||
public function getDuplicateAttributes(string $id): stdClass
|
||
{
|
||
if (!$id) {
|
||
throw new BadRequest("No ID.");
|
||
}
|
||
|
||
if (!$this->acl->check($this->entityType, AclTable::ACTION_CREATE)) {
|
||
throw new Forbidden("No 'create' access.");
|
||
}
|
||
|
||
if (!$this->acl->check($this->entityType, AclTable::ACTION_READ)) {
|
||
throw new Forbidden("No 'read' access.");
|
||
}
|
||
|
||
$entity = $this->getEntity($id);
|
||
|
||
if (!$entity) {
|
||
throw new NotFound("Record not found.");
|
||
}
|
||
|
||
$attributes = $this->createEntityDuplicator()->duplicate($entity);
|
||
|
||
if ($this->acl->getPermissionLevel(Acl\Permission::ASSIGNMENT) === AclTable::LEVEL_NO) {
|
||
unset($attributes->assignedUserId);
|
||
unset($attributes->assignedUserName);
|
||
unset($attributes->assignedUsersIds);
|
||
}
|
||
|
||
return $attributes;
|
||
}
|
||
|
||
/**
|
||
* @param TEntity $entity
|
||
* @noinspection PhpDocSignatureInspection
|
||
*/
|
||
private function afterCreateProcessDuplicating(Entity $entity, CreateParams $params): void
|
||
{
|
||
$duplicatingEntityId = $params->getDuplicateSourceId();
|
||
|
||
if (!$duplicatingEntityId) {
|
||
return;
|
||
}
|
||
|
||
/** @var ?TEntity $duplicatingEntity */
|
||
$duplicatingEntity = $this->entityManager->getEntityById($entity->getEntityType(), $duplicatingEntityId);
|
||
|
||
if (!$duplicatingEntity) {
|
||
return;
|
||
}
|
||
|
||
if (!$this->acl->check($duplicatingEntity, AclTable::ACTION_READ)) {
|
||
return;
|
||
}
|
||
|
||
/** @noinspection PhpDeprecationInspection */
|
||
$this->duplicateLinks($entity, $duplicatingEntity);
|
||
}
|
||
|
||
/**
|
||
* @param TEntity $entity
|
||
* @param TEntity $duplicatingEntity
|
||
* @noinspection PhpDocSignatureInspection
|
||
*/
|
||
private function duplicateLinks(Entity $entity, Entity $duplicatingEntity): void
|
||
{
|
||
$linkList = $this->metadata->get("recordDefs.$this->entityType.duplicateLinkList") ?? [];
|
||
|
||
foreach ($linkList as $link) {
|
||
$linkedList = $this->getRepository()
|
||
->getRelation($duplicatingEntity, $link)
|
||
->find();
|
||
|
||
foreach ($linkedList as $linked) {
|
||
$this->getRepository()
|
||
->getRelation($entity, $link)
|
||
->relate($linked);
|
||
}
|
||
}
|
||
}
|
||
|
||
public function prepareSearchParams(SearchParams $searchParams): SearchParams
|
||
{
|
||
$searchParams = $this->prepareSearchParamsSelect($searchParams);
|
||
|
||
if ($searchParams->getSelect() === null) {
|
||
$searchParams = $searchParams->withSelect(['*']);
|
||
}
|
||
|
||
return $searchParams
|
||
->withMaxTextAttributeLength(
|
||
$this->getMaxSelectTextAttributeLength()
|
||
);
|
||
}
|
||
|
||
protected function prepareSearchParamsSelect(SearchParams $searchParams): SearchParams
|
||
{
|
||
if ($this->metadata->get("recordDefs.$this->entityType.forceSelectAllAttributes")) {
|
||
return $searchParams->withSelect(null);
|
||
}
|
||
|
||
if ($searchParams->getSelect() === null) {
|
||
return $searchParams;
|
||
}
|
||
|
||
/** @var string[] $mandatoryAttributeList */
|
||
$mandatoryAttributeList = $this->metadata->get("recordDefs.$this->entityType.mandatoryAttributeList") ?? [];
|
||
/** @noinspection PhpDeprecationInspection */
|
||
$mandatoryAttributeList = array_merge($this->mandatorySelectAttributeList, $mandatoryAttributeList);
|
||
|
||
if ($mandatoryAttributeList === []) {
|
||
return $searchParams;
|
||
}
|
||
|
||
/** @noinspection PhpDeprecationInspection */
|
||
$select = array_unique(
|
||
array_merge(
|
||
$searchParams->getSelect(),
|
||
$mandatoryAttributeList
|
||
)
|
||
);
|
||
|
||
return $searchParams->withSelect($select);
|
||
}
|
||
|
||
/**
|
||
* Do not extend.
|
||
* @internal
|
||
*/
|
||
protected function prepareLinkSearchParams(SearchParams $searchParams, string $link): SearchParams
|
||
{
|
||
if ($searchParams->getSelect() === null) {
|
||
return $searchParams;
|
||
}
|
||
|
||
/** @noinspection PhpDeprecationInspection */
|
||
$list1 = $this->linkMandatorySelectAttributeList[$link] ?? [];
|
||
$list2 = $this->metadata->get("recordDefs.$this->entityType.relationships.$link.mandatoryAttributeList") ?? [];
|
||
|
||
if ($list1 === [] && $list2 === []) {
|
||
return $searchParams;
|
||
}
|
||
|
||
$select = array_unique(
|
||
array_merge(
|
||
$searchParams->getSelect(),
|
||
$list1,
|
||
$list2
|
||
)
|
||
);
|
||
|
||
return $searchParams->withSelect($select);
|
||
}
|
||
|
||
/**
|
||
* @param TEntity $entity
|
||
* @noinspection PhpDocSignatureInspection
|
||
*/
|
||
private function processApiBeforeCreateApiScript(Entity $entity, CreateParams $params): void
|
||
{
|
||
$processor = $this->injectableFactory->create(FormulaProcessor::class);
|
||
|
||
$processor->processBeforeCreate($entity, $params);
|
||
}
|
||
|
||
/**
|
||
* @param TEntity $entity
|
||
* @noinspection PhpDocSignatureInspection
|
||
*/
|
||
private function processApiBeforeUpdateApiScript(Entity $entity, UpdateParams $params): void
|
||
{
|
||
$processor = $this->injectableFactory->create(FormulaProcessor::class);
|
||
|
||
$processor->processBeforeUpdate($entity, $params);
|
||
}
|
||
|
||
/**
|
||
* @param TEntity $entity
|
||
* @param stdClass $data
|
||
* @return void
|
||
* @noinspection PhpDocSignatureInspection
|
||
* @deprecated As of v8.2.
|
||
* @todo Remove (or add types) in v10.0.
|
||
*/
|
||
protected function beforeCreateEntity(Entity $entity, $data)
|
||
{}
|
||
|
||
/**
|
||
* @param TEntity $entity
|
||
* @param stdClass $data
|
||
* @return void
|
||
* @noinspection PhpDocSignatureInspection
|
||
* @deprecated As of v8.2.
|
||
* @todo Remove (or add types) in v10.0.
|
||
*/
|
||
protected function afterCreateEntity(Entity $entity, $data)
|
||
{}
|
||
|
||
/**
|
||
* @param TEntity $entity
|
||
* @param stdClass $data
|
||
* @return void
|
||
* @noinspection PhpDocSignatureInspection
|
||
* @deprecated As of v8.2.
|
||
* @todo Remove (or add types) in v10.0.
|
||
*/
|
||
protected function beforeUpdateEntity(Entity $entity, $data)
|
||
{}
|
||
|
||
/**
|
||
* @param TEntity $entity
|
||
* @param stdClass $data
|
||
* @return void
|
||
* @noinspection PhpDocSignatureInspection
|
||
* @deprecated As of v8.2.
|
||
* @todo Remove (or add types) in v10.0.
|
||
*/
|
||
protected function afterUpdateEntity(Entity $entity, $data)
|
||
{}
|
||
|
||
/**
|
||
* @param TEntity $entity
|
||
* @return void
|
||
* @noinspection PhpDocSignatureInspection
|
||
* @deprecated As of v8.2.
|
||
* @todo Remove (or add types) in v10.0.
|
||
*/
|
||
protected function beforeDeleteEntity(Entity $entity)
|
||
{}
|
||
|
||
/**
|
||
* @param TEntity $entity
|
||
* @return void
|
||
* @noinspection PhpDocSignatureInspection
|
||
* @deprecated As of v8.2.
|
||
* @todo Remove (or add types) in v10.0.
|
||
*/
|
||
protected function afterDeleteEntity(Entity $entity)
|
||
{}
|
||
|
||
private function createBinding(): BindingContainer
|
||
{
|
||
return BindingContainerBuilder::create()
|
||
->bindInstance(User::class, $this->user)
|
||
->bindInstance(Acl::class, $this->acl)
|
||
->build();
|
||
}
|
||
|
||
private function getRecordHookManager(): HookManager
|
||
{
|
||
if (!$this->recordHookManager) {
|
||
$this->recordHookManager =
|
||
$this->injectableFactory->createWithBinding(HookManager::class, $this->createBinding());
|
||
}
|
||
|
||
return $this->recordHookManager;
|
||
}
|
||
|
||
/**
|
||
* @throws Forbidden
|
||
*/
|
||
private function checkEntityCreateAccess(Entity $entity): void
|
||
{
|
||
if (!$this->acl->check($entity, AclTable::ACTION_CREATE)) {
|
||
throw new ForbiddenSilent("No create access.");
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Filter input by the read-only-pre-save dynamic logic.
|
||
*
|
||
* @since 9.1.0
|
||
* @internal
|
||
*/
|
||
public function filterInputReadOnlySaved(Entity $entity, stdClass $data): void
|
||
{
|
||
$processor = $this->injectableFactory->create(InputFilterProcessor::class);
|
||
|
||
$processor->process($entity, $data);
|
||
}
|
||
}
|