Initial commit

This commit is contained in:
root
2026-01-19 17:44:46 +01:00
commit 823af8b11d
8721 changed files with 1130846 additions and 0 deletions

View File

@@ -0,0 +1,746 @@
<?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\Access;
use Espo\Core\Acl;
use Espo\Core\Acl\LinkChecker;
use Espo\Core\Acl\LinkChecker\LinkCheckerFactory;
use Espo\Core\Acl\Table as AclTable;
use Espo\Core\Exceptions\Error\Body as ErrorBody;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Exceptions\ForbiddenSilent;
use Espo\Core\InjectableFactory;
use Espo\Core\Name\Field;
use Espo\Core\ORM\Defs\AttributeParam;
use Espo\Core\ORM\Type\FieldType;
use Espo\Core\Utils\Metadata;
use Espo\Entities\User;
use Espo\Modules\Crm\Entities\Account;
use Espo\Modules\Crm\Entities\Contact;
use Espo\ORM\Defs;
use Espo\ORM\Defs\EntityDefs;
use Espo\ORM\Defs\FieldDefs;
use Espo\ORM\Defs\RelationDefs;
use Espo\ORM\Entity;
use Espo\ORM\EntityManager;
use Espo\ORM\Type\AttributeType;
use Espo\ORM\Type\RelationType;
use stdClass;
/**
* Check access for record linking. When linking directly through relationships or via link fields.
* Also loads foreign name attributes.
*/
class LinkCheck
{
/** @var array<string, LinkChecker<Entity, Entity>> */
private $linkCheckerCache = [];
/** @var string[] */
private array $oneFieldTypeList = [
FieldType::LINK,
FieldType::LINK_PARENT,
FieldType::LINK_ONE,
FieldType::FILE,
FieldType::IMAGE,
];
/** @var string[] */
private array $manyFieldTypeList = [
FieldType::LINK_MULTIPLE,
FieldType::ATTACHMENT_MULTIPLE,
];
public function __construct(
private Defs $ormDefs,
private EntityManager $entityManager,
private Acl $acl,
private Metadata $metadata,
private InjectableFactory $injectableFactory,
private User $user,
) {}
/**
* Checks relation fields set in an entity (link-multiple, link and others).
*
* @throws Forbidden
*/
public function processFields(Entity $entity): void
{
$this->processLinkMultipleFields($entity);
$this->processLinkFields($entity);
}
/**
* @throws Forbidden
*/
private function processLinkMultipleFields(Entity $entity): void
{
$entityType = $entity->getEntityType();
$entityDefs = $this->entityManager
->getDefs()
->getEntity($entityType);
$typeList = [
Entity::HAS_MANY,
Entity::MANY_MANY,
Entity::HAS_CHILDREN,
];
foreach ($entityDefs->getRelationList() as $relationDefs) {
$name = $relationDefs->getName();
if (!in_array($relationDefs->getType(), $typeList)) {
continue;
}
$attribute = $name . 'Ids';
$namesAttribute = $name . 'Names';
if (
!$entityDefs->hasAttribute($attribute) ||
!$entity->isAttributeChanged($attribute)
) {
continue;
}
/** @var string[] $ids */
$ids = $entity->get($attribute) ?? [];
/** @var string[] $oldIds */
$oldIds = $entity->getFetched($attribute) ?? [];
$setIds = $ids;
$ids = array_values(array_diff($ids, $oldIds));
$removedIds = array_values(array_diff($oldIds, $ids));
if ($ids === [] && $removedIds === []) {
continue;
}
$this->processCheckLinkWithoutField($entityDefs, $name, false, $setIds);
$names = $this->prepareNames($entity, $namesAttribute, $setIds);
foreach ($ids as $id) {
$foreignEntity = $this->processLinkedRecordsCheckItem($entity, $relationDefs, $id);
if ($foreignEntity) {
$names->$id = $foreignEntity->get(Field::NAME);
}
}
if (!$entityDefs->tryGetAttribute($namesAttribute)?->getParam(AttributeParam::IS_LINK_MULTIPLE_NAME_MAP)) {
continue;
}
$entity->set($namesAttribute, $names);
}
}
/**
* @param ?string[] $ids
* @throws Forbidden
*/
private function processCheckLinkWithoutField(
EntityDefs $entityDefs,
string $name,
bool $isOne,
?array $ids = null
): void {
$fieldTypes = $isOne ? $this->oneFieldTypeList : $this->manyFieldTypeList;
$hasField =
$entityDefs->hasField($name) &&
in_array($entityDefs->getField($name)->getType(), $fieldTypes);
if ($hasField) {
return;
}
if ($isOne) {
throw new ForbiddenSilent("Cannot set ID attribute for link '$name' as there's no link field.");
}
if ($ids !== null && count($ids) > 1) {
throw new ForbiddenSilent("Cannot set multiple IDs for link '$name' as there's no link-multiple field.");
}
$forbiddenLinkList = $this->acl->getScopeForbiddenLinkList($entityDefs->getName(), AclTable::ACTION_EDIT);
if (!in_array($name, $forbiddenLinkList)) {
return;
}
throw ForbiddenSilent::createWithBody(
"No access to link $name.",
ErrorBody::create()
->withMessageTranslation('cannotRelateForbiddenLink', null, ['link' => $name])
->encode()
);
}
/**
* @throws Forbidden
*/
private function processLinkedRecordsCheckItem(
Entity $entity,
RelationDefs $defs,
string $id,
bool $isOne = false
): ?Entity {
$entityType = $entity->getEntityType();
$link = $defs->getName();
if ($this->getParam($entityType, $link, 'linkCheckDisabled')) {
return null;
}
$foreignEntityType = null;
if ($defs->getType() === RelationType::BELONGS_TO_PARENT) {
$foreignEntityType = $entity->get($link . 'Type');
}
if (!$foreignEntityType && !$defs->hasForeignEntityType()) {
return null;
}
$foreignEntityType ??= $defs->getForeignEntityType();
$foreignEntity = $this->entityManager->getEntityById($foreignEntityType, $id);
if (!$foreignEntity) {
throw ForbiddenSilent::createWithBody(
"Can't relate with non-existing record. entity type: $entityType, link: $link.",
ErrorBody::create()
->withMessageTranslation(
'cannotRelateNonExisting', null, ['foreignEntityType' => $foreignEntityType])
->encode()
);
}
$toSkip = $this->linkForeignAccessCheck($isOne, $entityType, $link, $foreignEntity);
if ($toSkip) {
return $foreignEntity;
}
$this->linkEntityAccessCheck($entity, $foreignEntity, $link);
return $foreignEntity;
}
/**
* @throws Forbidden
*/
private function linkForeignAccessCheck(
bool $isOne,
string $entityType,
string $link,
Entity $foreignEntity
): bool {
if ($isOne) {
return $this->linkForeignAccessCheckOne($entityType, $link, $foreignEntity);
}
return $this->linkForeignAccessCheckMany($entityType, $link, $foreignEntity, true);
}
private function getParam(string $entityType, string $link, string $param): mixed
{
return $this->metadata->get(['recordDefs', $entityType, 'relationships', $link, $param]);
}
/**
* Check access to a specific link.
*
* @throws Forbidden
*/
public function processLink(Entity $entity, string $link): void
{
$entityType = $entity->getEntityType();
/** @var AclTable::ACTION_*|null $action */
$action = $this->getParam($entityType, $link, 'linkRequiredAccess');
if (!$action) {
$action = AclTable::ACTION_EDIT;
}
if (!$this->acl->check($entity, $action)) {
throw ForbiddenSilent::createWithBody(
"No record access for link operation ($entityType:$link).",
ErrorBody::create()
->withMessageTranslation('noAccessToRecord', null, ['action' => $action])
->encode()
);
}
}
/**
* Check unlink access to a specific link.
*
* @throws Forbidden
*/
public function processUnlink(Entity $entity, string $link): void
{
$this->processLink($entity, $link);
}
/**
* Check link access for a specific foreign entity.
*
* @throws Forbidden
*/
public function processLinkForeign(Entity $entity, string $link, Entity $foreignEntity): void
{
$this->processLinkForeignInternal($entity, $link, $foreignEntity);
$this->processLinkAlreadyLinkedCheck($entity, $link, $foreignEntity);
}
/**
* Check link access for a specific foreign entity.
*
* @throws Forbidden
*/
private function processLinkForeignInternal(Entity $entity, string $link, Entity $foreignEntity): void
{
$toSkip = $this->linkForeignAccessCheckMany($entity->getEntityType(), $link, $foreignEntity);
if ($toSkip) {
return;
}
$this->linkEntityAccessCheck($entity, $foreignEntity, $link);
}
/**
* Check unlink access for a specific foreign entity.
*
* @throws Forbidden
*/
public function processUnlinkForeign(Entity $entity, string $link, Entity $foreignEntity): void
{
$this->processLinkForeignInternal($entity, $link, $foreignEntity);
$this->processUnlinkForeignRequired($entity, $link, $foreignEntity);
}
/**
* Check access to foreign record for has-many and many-many links.
*
* @return bool True indicates that the link checker should be bypassed.
* @throws Forbidden
*/
private function linkForeignAccessCheckMany(
string $entityType,
string $link,
Entity $foreignEntity,
bool $fromUpdate = false
): bool {
/** @var AclTable::ACTION_* $action */
$action = $this->getParam($entityType, $link, 'linkRequiredForeignAccess') ?? AclTable::ACTION_EDIT;
if ($this->getParam($entityType, $link, 'linkForeignAccessCheckDisabled')) {
return true;
}
$fieldDefs = $fromUpdate ?
$this->entityManager
->getDefs()
->getEntity($entityType)
->tryGetField($link) :
null;
if (
$fromUpdate &&
$fieldDefs &&
in_array($fieldDefs->getType(), $this->manyFieldTypeList)
) {
$action = AclTable::ACTION_READ;
if ($this->checkInDefaults($fieldDefs, $link, $foreignEntity)) {
return true;
}
}
if (
$action === AclTable::ACTION_READ &&
$this->checkIsAllowedForPortal($foreignEntity)
) {
return true;
}
if ($this->acl->check($foreignEntity, $action)) {
return false;
}
if ($this->getLinkChecker($entityType, $link)) {
return false;
}
$body = ErrorBody::create();
$body = $fromUpdate ?
$body->withMessageTranslation('cannotRelateForbidden', null, [
'foreignEntityType' => $foreignEntity->getEntityType(),
'action' => $action,
]) :
$body->withMessageTranslation('noAccessToForeignRecord', null, ['action' => $action]);
throw ForbiddenSilent::createWithBody(
"No foreign record access for link operation ($entityType:$link).",
$body->encode()
);
}
public function checkIsAllowedForPortal(Entity $foreignEntity): bool
{
if (!$this->user->isPortal()) {
return false;
}
if (
$foreignEntity->getEntityType() === Account::ENTITY_TYPE &&
$this->user->getAccounts()->hasId($foreignEntity->getId())
) {
return true;
}
if (
$foreignEntity->getEntityType() === Contact::ENTITY_TYPE &&
$this->user->getContactId() === $foreignEntity->getId()
) {
return true;
}
return false;
}
/**
* @throws Forbidden
*/
private function linkEntityAccessCheck(Entity $entity, Entity $foreignEntity, string $link): void
{
$entityType = $entity->getEntityType();
$checker = $this->getLinkChecker($entityType, $link);
if (!$checker) {
return;
}
if ($checker->check($this->user, $entity, $foreignEntity)) {
return;
}
throw ForbiddenSilent::createWithBody(
"No access for link operation ($entityType:$link).",
ErrorBody::create()
->withMessageTranslation('noLinkAccess', null, [
'foreignEntityType' => $foreignEntity->getEntityType(),
'link' => $link,
])
);
}
/**
* @return ?LinkChecker<Entity, Entity>
*/
private function getLinkChecker(string $entityType, string $link): ?LinkChecker
{
$key = $entityType . '_' . $link;
if (array_key_exists($key, $this->linkCheckerCache)) {
return $this->linkCheckerCache[$key];
}
$factory = $this->injectableFactory->create(LinkCheckerFactory::class);
if (!$factory->isCreatable($entityType, $link)) {
return null;
}
$checker = $factory->create($entityType, $link);
$this->linkCheckerCache[$link] = $checker;
return $checker;
}
/**
* @throws Forbidden
*/
private function processUnlinkForeignRequired(Entity $entity, string $link, Entity $foreignEntity): void
{
$relationDefs = $this->ormDefs
->getEntity($entity->getEntityType())
->tryGetRelation($link);
if (!$relationDefs) {
return;
}
if (
!$relationDefs->hasForeignEntityType() ||
!$relationDefs->hasForeignRelationName()
) {
return;
}
$foreignLink = $relationDefs->getForeignRelationName();
$foreignRelationDefs = $this->ormDefs
->getEntity($foreignEntity->getEntityType())
->tryGetRelation($foreignLink);
if (!$foreignRelationDefs) {
return;
}
if (
!in_array($foreignRelationDefs->getType(), [
RelationType::BELONGS_TO,
RelationType::HAS_ONE,
RelationType::BELONGS_TO_PARENT,
])
) {
return;
}
$foreignFieldDefs = $this->ormDefs
->getEntity($foreignEntity->getEntityType())
->tryGetField($foreignLink);
if (!$foreignFieldDefs) {
return;
}
if (!$foreignFieldDefs->getParam('required')) {
return;
}
throw ForbiddenSilent::createWithBody(
"Can't unlink required field ({$foreignEntity->getEntityType()}:$foreignLink}).",
ErrorBody::create()
->withMessageTranslation('cannotUnrelateRequiredLink')
->encode()
);
}
/**
* @throws Forbidden
*/
private function processLinkFields(Entity $entity): void
{
$entityType = $entity->getEntityType();
$entityDefs = $this->entityManager
->getDefs()
->getEntity($entityType);
$typeList = [
Entity::BELONGS_TO,
Entity::BELONGS_TO_PARENT,
Entity::HAS_ONE,
];
foreach ($entityDefs->getRelationList() as $relationDefs) {
$name = $relationDefs->getName();
$attribute = $name . 'Id';
$nameAttribute = $name . 'Name';
if (
!in_array($relationDefs->getType(), $typeList) ||
!$entityDefs->hasAttribute($attribute) ||
!$entity->isAttributeChanged($attribute) ||
$entity->get($attribute) === null
) {
continue;
}
$this->processCheckLinkWithoutField($entityDefs, $name, true);
$id = $entity->get($attribute);
$foreignEntity = $this->processLinkedRecordsCheckItem($entity, $relationDefs, $id, true);
if (!$foreignEntity) {
continue;
}
$nameAttributeDefs = $entityDefs->tryGetAttribute($nameAttribute);
if (!$nameAttributeDefs) {
return;
}
if (
$nameAttributeDefs->getType() === AttributeType::FOREIGN ||
$nameAttributeDefs->isNotStorable()
) {
$foreignName = $relationDefs->getParam('foreignName') ?? 'name';
$entity->set($nameAttribute, $foreignEntity->get($foreignName));
}
}
}
/**
* Check access to foreign record for belongs-to, has-one and belongs-to-parent links.
*
* @return bool True indicates that the link checker should be bypassed.
* @throws Forbidden
*/
private function linkForeignAccessCheckOne(string $entityType, string $link, Entity $foreignEntity): bool
{
if ($this->getParam($entityType, $link, 'linkForeignAccessCheckDisabled')) {
return true;
}
$fieldDefs = $this->entityManager
->getDefs()
->getEntity($entityType)
->tryGetField($link);
if (
$fieldDefs &&
in_array($fieldDefs->getType(), $this->oneFieldTypeList)
) {
if ($this->checkIsDefault($fieldDefs, $link, $foreignEntity)) {
return true;
}
}
if ($this->checkIsAllowedForPortal($foreignEntity)) {
return true;
}
if ($this->acl->check($foreignEntity, AclTable::ACTION_READ)) {
return false;
}
if ($this->getLinkChecker($entityType, $link)) {
return false;
}
throw ForbiddenSilent::createWithBody(
"No foreign record access for link operation ($entityType:$link).",
ErrorBody::create()
->withMessageTranslation('cannotRelateForbidden', null, [
'foreignEntityType' => $foreignEntity->getEntityType(),
'action' => AclTable::ACTION_READ,
])
->encode()
);
}
private function checkInDefaults(FieldDefs $fieldDefs, string $link, Entity $foreignEntity): bool
{
/** @var string[] $defaults */
$defaults = $this->getDefault($fieldDefs, $link . 'Ids') ?? [];
return in_array($foreignEntity->getId(), $defaults);
}
private function checkIsDefault(FieldDefs $fieldDefs, string $link, Entity $foreignEntity): bool
{
return $foreignEntity->getId() === $this->getDefault($fieldDefs, $link . 'Id');
}
private function getDefault(FieldDefs $fieldDefs, string $attribute): mixed
{
$defaultAttributes = (object) ($fieldDefs->getParam('defaultAttributes') ?? []);
return $defaultAttributes->$attribute ?? null;
}
/**
* @param string[] $setIds
*/
private function prepareNames(Entity $entity, string $namesAttribute, array $setIds): stdClass
{
$oldNames = $entity->getFetched($namesAttribute);
if (!$oldNames instanceof stdClass) {
$oldNames = (object) [];
}
$names = (object) [];
foreach ($setIds as $id) {
if (isset($oldNames->$id)) {
$names->$id = $oldNames->$id;
}
}
return $names;
}
/**
* @throws Forbidden
*/
private function processLinkAlreadyLinkedCheck(Entity $entity, string $link, Entity $foreignEntity): void
{
if (!$this->getParam($entity->getEntityType(), $link, 'linkOnlyNotLinked')) {
return;
}
$entityType = $entity->getEntityType();
$foreign = $this->ormDefs
->getEntity($entityType)
->tryGetRelation($link)
?->tryGetForeignRelationName();
if (!$foreign) {
return;
}
$one = $this->entityManager
->getRDBRepository($foreignEntity->getEntityType())
->getRelation($foreignEntity, $foreign)
->findOne();
if (!$one) {
return;
}
throw ForbiddenSilent::createWithBody(
"Cannot link as the record is already linked ($entityType:$link).",
ErrorBody::create()
->withMessageTranslation('cannotLinkAlreadyLinked')
->encode()
);
}
}

View File

@@ -0,0 +1,38 @@
<?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\ActionHistory;
class Action
{
public const CREATE = 'create';
public const READ = 'read';
public const UPDATE = 'update';
public const DELETE = 'delete';
}

View File

@@ -0,0 +1,45 @@
<?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\ActionHistory;
use Espo\ORM\Entity;
/**
* Logs actions users do with records.
*/
interface ActionLogger
{
/**
* Log an action.
*
* @param Action::* $action
*/
public function log(string $action, Entity $entity): void;
}

View File

@@ -0,0 +1,64 @@
<?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\ActionHistory;
use Espo\Core\Field\LinkParent;
use Espo\Entities\ActionHistoryRecord;
use Espo\Entities\User;
use Espo\ORM\Entity;
use Espo\ORM\EntityManager;
class DefaultActionLogger implements ActionLogger
{
public function __construct(
private EntityManager $entityManager,
private User $user
) {}
/**
* @inheritDoc
*/
public function log(string $action, Entity $entity): void
{
$historyRecord = $this->entityManager
->getRepositoryByClass(ActionHistoryRecord::class)
->getNew();
$historyRecord
->setAction($action)
->setUserId($this->user->getId())
->setAuthTokenId($this->user->get('authTokenId'))
->setAuthLogRecordId($this->user->get('authLogRecordId'))
->setIpAddress($this->user->get('ipAddress'))
->setTarget(LinkParent::createFromEntity($entity));
$this->entityManager->saveEntity($historyRecord);
}
}

View File

@@ -0,0 +1,149 @@
<?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\ORM\Collection as OrmCollection;
use Espo\ORM\Entity;
use Espo\ORM\EntityCollection;
use stdClass;
/**
* Contains an ORM collection and total number of records.
*
* @template-covariant TEntity of Entity
*/
class Collection
{
public const TOTAL_HAS_MORE = -1;
public const TOTAL_HAS_NO_MORE = -2;
/**
* @param OrmCollection<TEntity> $collection
*/
public function __construct(
private OrmCollection $collection,
private ?int $total = null
) {}
/**
* Get a total number of records in DB (that matches applied search parameters).
*/
public function getTotal(): ?int
{
return $this->total;
}
/**
* Get an ORM collection.
*
* @return OrmCollection<TEntity>
*/
public function getCollection(): OrmCollection
{
return $this->collection;
}
/**
* Get a value map list.
*
* @return stdClass[]
*/
public function getValueMapList(): array
{
if (
$this->collection instanceof EntityCollection &&
!$this->collection->getEntityType()
) {
$list = [];
foreach ($this->collection as $e) {
$item = $e->getValueMap();
$item->_scope = $e->getEntityType();
$list[] = $item;
}
return $list;
}
return $this->collection->getValueMapList();
}
/**
* Create.
*
* @template CEntity of Entity
* @param OrmCollection<CEntity> $collection
* @return self<CEntity>
*/
public static function create(OrmCollection $collection, ?int $total = null): self
{
return new self($collection, $total);
}
/**
* Create w/o count.
*
* @template CEntity of Entity
* @param OrmCollection<CEntity> $collection
* @return self<CEntity>
*/
public static function createNoCount(OrmCollection $collection, ?int $maxSize): self
{
if (
$maxSize !== null &&
$collection instanceof EntityCollection &&
count($collection) > $maxSize
) {
$copyCollection = new EntityCollection([...$collection], $collection->getEntityType());
unset($copyCollection[count($copyCollection) - 1]);
return new self($copyCollection, self::TOTAL_HAS_MORE);
}
return new self($collection, self::TOTAL_HAS_NO_MORE);
}
/**
* To API output. To be used in API actions.
*
* @since 9.1.0
*/
public function toApiOutput(): stdClass
{
return (object) [
'total' => $this->getTotal(),
'list' => $this->getValueMapList(),
];
}
}

View File

@@ -0,0 +1,46 @@
<?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\ConcurrencyControl\Optimistic;
use stdClass;
readonly class Result
{
/**
* @param string[] $fieldList Changed fields.
* @param stdClass $values Previous values.
* @param int $versionNumber A previous version number.
*/
public function __construct(
public array $fieldList,
public stdClass $values,
public int $versionNumber,
) {}
}

View File

@@ -0,0 +1,116 @@
<?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\ConcurrencyControl;
use Espo\Core\Name\Field;
use Espo\Core\Record\ConcurrencyControl\Optimistic\Result;
use Espo\Core\Utils\FieldUtil;
use Espo\ORM\BaseEntity;
use Espo\ORM\Defs\Params\FieldParam;
use Espo\ORM\Entity;
use Espo\ORM\EntityManager;
/**
* @internal
*/
class OptimisticProcessor
{
public function __construct(
private EntityManager $entityManager,
private FieldUtil $fieldUtil,
) {}
public function process(Entity $entity, int $versionNumber): ?Result
{
$previousVersionNumber = $entity->getFetched(Field::VERSION_NUMBER);
if ($previousVersionNumber === null) {
return null;
}
if ($versionNumber === $previousVersionNumber) {
return null;
}
$changedFieldList = [];
$entityDefs = $this->entityManager
->getDefs()
->getEntity($entity->getEntityType());
foreach ($entityDefs->getFieldList() as $fieldDefs) {
$field = $fieldDefs->getName();
if (
$fieldDefs->getParam('optimisticConcurrencyControlIgnore') ||
$fieldDefs->getParam(FieldParam::READ_ONLY)
) {
continue;
}
foreach ($this->fieldUtil->getActualAttributeList($entityDefs->getName(), $field) as $attribute) {
if (
$entity instanceof BaseEntity &&
!$entity->isAttributeWritten($attribute)
) {
continue;
}
if (!$entity->hasFetched($attribute)) {
continue;
}
if ($entity->isAttributeChanged($attribute)) {
$changedFieldList[] = $field;
continue 2;
}
}
}
if ($changedFieldList === []) {
return null;
}
$values = (object) [];
foreach ($changedFieldList as $field) {
foreach ($this->fieldUtil->getAttributeList($entityDefs->getName(), $field) as $attribute) {
$values->$attribute = $entity->getFetched($attribute);
}
}
return new Result(
fieldList: $changedFieldList,
values: $values,
versionNumber: $previousVersionNumber,
);
}
}

View File

@@ -0,0 +1,74 @@
<?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;
/**
* Immutable.
*/
class CreateParams
{
private bool $skipDuplicateCheck = false;
private ?string $duplicateSourceId = null;
public function __construct() {}
public function withSkipDuplicateCheck(bool $skipDuplicateCheck = true): self
{
$obj = clone $this;
$obj->skipDuplicateCheck = $skipDuplicateCheck;
return $obj;
}
public function withDuplicateSourceId(?string $duplicateSourceId): self
{
$obj = clone $this;
$obj->duplicateSourceId = $duplicateSourceId;
return $obj;
}
public function skipDuplicateCheck(): bool
{
return $this->skipDuplicateCheck;
}
public function getDuplicateSourceId(): ?string
{
return $this->duplicateSourceId;
}
public static function create(): self
{
return new self();
}
}

View File

@@ -0,0 +1,53 @@
<?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\Api\Request;
class CreateParamsFetcher
{
public function __construct() {}
public function fetch(Request $request): CreateParams
{
$data = $request->getParsedBody();
$skipDuplicateCheck = $request->hasHeader('X-Skip-Duplicate-Check') ?
strtolower($request->getHeader('X-Skip-Duplicate-Check') ?? '') === 'true' :
$data->_skipDuplicateCheck ?? // legacy
false;
$duplicateSourceId = $request->getHeader('X-Duplicate-Source-Id');
return CreateParams::create()
->withSkipDuplicateCheck($skipDuplicateCheck)
->withDuplicateSourceId($duplicateSourceId);
}
}

View File

@@ -0,0 +1,66 @@
<?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\ORM\Entity;
use stdClass;
/**
* @template TEntity of Entity
*/
interface Crud
{
/**
* Create a record.
*
* @return TEntity
*/
public function create(stdClass $data, CreateParams $params): Entity;
/**
* Read a record.
*
* @return TEntity
*/
public function read(string $id, ReadParams $params): Entity;
/**
* Update a record.
*
* @return TEntity
*/
public function update(string $id, stdClass $data, UpdateParams $params): Entity;
/**
* Delete a record.
*/
public function delete(string $id, DeleteParams $params): void;
}

View File

@@ -0,0 +1,278 @@
<?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\Defaults;
use Espo\Core\Acl;
use Espo\Core\Acl\Permission;
use Espo\Core\Acl\Table as AclTable;
use Espo\Core\Currency\ConfigDataProvider as CurrencyConfigDataProvider;
use Espo\Core\Field\Link;
use Espo\Core\Field\LinkMultiple;
use Espo\Core\Field\LinkMultipleItem;
use Espo\Core\Name\Field;
use Espo\Core\ORM\Entity as CoreEntity;
use Espo\Core\ORM\Type\FieldType;
use Espo\Core\Utils\FieldUtil;
use Espo\Core\Utils\Metadata;
use Espo\Entities\User;
use Espo\Modules\Crm\Entities\Account;
use Espo\Modules\Crm\Entities\Contact;
use Espo\ORM\Defs\Params\FieldParam;
use Espo\ORM\Entity;
use Espo\ORM\EntityManager;
use RuntimeException;
/**
* @implements Populator<Entity>
*/
class DefaultPopulator implements Populator
{
public function __construct(
private Acl $acl,
private User $user,
private FieldUtil $fieldUtil,
private EntityManager $entityManager,
private CurrencyConfigDataProvider $currencyConfig,
private Metadata $metadata,
) {}
public function populate(Entity $entity): void
{
$this->processAssignedUser($entity);
$this->processDefaultTeam($entity);
$this->processCurrency($entity);
$this->processPortal($entity);
}
/**
* If no edit access to assignedUser field.
*/
private function isAssignedUserShouldBeSetWithSelf(string $entityType): bool
{
if ($this->user->isPortal()) {
return false;
}
$defs = $this->entityManager->getDefs()->getEntity($entityType);
if ($defs->tryGetField(Field::ASSIGNED_USER)?->getType() !== FieldType::LINK) {
return false;
}
if (
$this->acl->getPermissionLevel(Permission::ASSIGNMENT) === AclTable::LEVEL_NO &&
!$this->user->isApi()
) {
return true;
}
if (!$this->acl->checkField($entityType, Field::ASSIGNED_USER, AclTable::ACTION_EDIT)) {
return true;
}
return false;
}
private function toAddDefaultTeam(Entity $entity): bool
{
if ($this->user->isPortal()) {
return false;
}
if (!$this->user->getDefaultTeam()) {
return false;
}
if (!$entity instanceof CoreEntity) {
return false;
}
$entityType = $entity->getEntityType();
$defs = $this->entityManager->getDefs()->getEntity($entityType);
if ($defs->tryGetField(Field::TEAMS)?->getType() !== FieldType::LINK_MULTIPLE) {
return false;
}
if ($entity->hasLinkMultipleId(Field::TEAMS, $this->user->getDefaultTeam()->getId())) {
return false;
}
if ($this->acl->getPermissionLevel(Permission::ASSIGNMENT) === AclTable::LEVEL_NO) {
return true;
}
if (!$this->acl->checkField($entityType, Field::TEAMS, AclTable::ACTION_EDIT)) {
return true;
}
return false;
}
private function processCurrency(Entity $entity): void
{
$entityType = $entity->getEntityType();
foreach ($this->fieldUtil->getEntityTypeFieldList($entityType) as $field) {
$type = $this->fieldUtil->getEntityTypeFieldParam($entityType, $field, FieldParam::TYPE);
if ($type !== FieldType::CURRENCY) {
continue;
}
$currencyAttribute = $field . 'Currency';
if ($entity->get($field) !== null && !$entity->get($currencyAttribute)) {
$entity->set($currencyAttribute, $this->currencyConfig->getDefaultCurrency());
}
}
}
private function processDefaultTeam(Entity $entity): void
{
if (!$this->toAddDefaultTeam($entity)) {
return;
}
$defaultTeamId = $this->user->getDefaultTeam()?->getId();
if (!$defaultTeamId || !$entity instanceof CoreEntity) {
throw new RuntimeException();
}
$entity->addLinkMultipleId(Field::TEAMS, $defaultTeamId);
$teamsNames = $entity->get(Field::TEAMS . 'Names');
if (!$teamsNames || !is_object($teamsNames)) {
$teamsNames = (object)[];
}
$teamsNames->$defaultTeamId = $this->user->getDefaultTeam()?->getName();
$entity->set(Field::TEAMS . 'Names', $teamsNames);
}
private function processAssignedUser(Entity $entity): void
{
if (!$this->isAssignedUserShouldBeSetWithSelf($entity->getEntityType())) {
return;
}
$entity->set(Field::ASSIGNED_USER . 'Id', $this->user->getId());
$entity->set(Field::ASSIGNED_USER . 'Name', $this->user->getName());
}
private function processPortal(Entity $entity): void
{
if (!$this->user->isPortal()) {
return;
}
$this->processPortalAccount($entity);
$this->processPortalContact($entity);
}
private function processPortalAccount(Entity $entity): void
{
/** @var ?string $link */
$link = $this->metadata->get("aclDefs.{$entity->getEntityType()}.accountLink");
if (!$link) {
return;
}
$account = $this->user->getContact()?->getAccount();
if (!$account) {
return;
}
$this->processPortalRecord($entity, $link, $account);
}
private function processPortalContact(Entity $entity): void
{
/** @var ?string $link */
$link = $this->metadata->get("aclDefs.{$entity->getEntityType()}.contactLink");
if (!$link) {
return;
}
$contact = $this->user->getContact();
if (!$contact) {
return;
}
$this->processPortalRecord($entity, $link, $contact);
}
private function processPortalRecord(Entity $entity, string $link, Account|Contact $record): void
{
if (!$entity instanceof CoreEntity) {
return;
}
$fieldDefs = $this->entityManager
->getDefs()
->getEntity($entity->getEntityType())
->tryGetField($link);
if (!$fieldDefs) {
return;
}
if (
$fieldDefs->getType() === FieldType::LINK ||
$fieldDefs->getType() === FieldType::LINK_ONE
) {
if ($entity->has($link . 'Id')) {
return;
}
$entity->setValueObject($link, Link::create($record->getId(), $record->getName()));
return;
}
if ($fieldDefs->getType() === FieldType::LINK_MULTIPLE) {
if ($entity->has($link . 'Ids')) {
return;
}
$linkMultiple = LinkMultiple::create([LinkMultipleItem::create($record->getId(), $record->getName())]);
$entity->setValueObject($link, $linkMultiple);
}
}
}

View File

@@ -0,0 +1,47 @@
<?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\Defaults;
use Espo\ORM\Entity;
/**
* Populate default values.
*
* @template TEntity of Entity
* @noinspection PhpUnused
*/
interface Populator
{
/**
* @param TEntity $entity
* @noinspection PhpDocSignatureInspection
*/
public function populate(Entity $entity): void;
}

View File

@@ -0,0 +1,78 @@
<?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\Defaults;
use Espo\Core\Acl;
use Espo\Core\Binding\BindingContainerBuilder;
use Espo\Core\InjectableFactory;
use Espo\Core\Utils\Metadata;
use Espo\Entities\User;
use Espo\ORM\Entity;
class PopulatorFactory
{
/** @var class-string<DefaultPopulator> */
private string $defaultClassName = DefaultPopulator::class;
public function __construct(
private InjectableFactory $injectableFactory,
private Metadata $metadata,
private User $user,
private Acl $acl
) {}
/**
* @return Populator<Entity>
*/
public function create(string $entityType): Populator
{
$binding = BindingContainerBuilder::create()
->bindInstance(User::class, $this->user)
->bindInstance(Acl::class, $this->acl)
->build();
return $this->injectableFactory->createWithBinding($this->getClassName($entityType), $binding);
}
/**
* @return class-string<Populator<Entity>>
*/
private function getClassName(string $entityType): string
{
/** @var ?class-string<Populator<Entity>> $className */
$className = $this->metadata->get("recordDefs.$entityType.defaultsPopulatorClassName");
if ($className) {
return $className;
}
return $this->defaultClassName;
}
}

View File

@@ -0,0 +1,43 @@
<?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;
/**
* Immutable.
*/
class DeleteParams
{
public function __construct() {}
public static function create(): self
{
return new self();
}
}

View File

@@ -0,0 +1,42 @@
<?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\Api\Request;
class DeleteParamsFetcher
{
public function __construct() {}
public function fetch(Request $request): DeleteParams
{
return DeleteParams::create();
}
}

View File

@@ -0,0 +1,76 @@
<?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\Deleted;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\ORM\Repository\Option\SaveOption;
use Espo\Core\Utils\Metadata;
use Espo\ORM\Entity;
use Espo\ORM\EntityManager;
use Espo\ORM\Name\Attribute;
/**
* @implements Restorer<Entity>
*/
class DefaultRestorer implements Restorer
{
public function __construct(
private EntityManager $entityManager,
private Metadata $metadata,
) {}
public function restore(Entity $entity): void
{
if (!$entity->get(Attribute::DELETED)) {
throw new Forbidden("No 'deleted' attribute.");
}
$this->entityManager
->getTransactionManager()
->run(fn () => $this->restoreInTransaction($entity));
}
private function restoreInTransaction(Entity $entity): void
{
$repository = $this->entityManager->getRDBRepository($entity->getEntityType());
$repository->restoreDeleted($entity->getId());
if (
$entity->hasAttribute('deleteId') &&
$this->metadata->get("entityDefs.{$entity->getEntityType()}.deleteId")
) {
$this->entityManager->refreshEntity($entity);
$entity->set('deleteId', '0');
$repository->save($entity, [SaveOption::SILENT => true]);
}
}
}

View File

@@ -0,0 +1,47 @@
<?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\Deleted;
use Espo\Core\Exceptions\Conflict;
use Espo\Core\Exceptions\Forbidden;
use Espo\ORM\Entity;
/**
* @template TEntity of Entity
*/
interface Restorer
{
/**
* @param TEntity $entity A deleted entity.
* @throws Forbidden
* @throws Conflict
*/
public function restore(Entity $entity): void;
}

View File

@@ -0,0 +1,111 @@
<?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\Duplicator;
use Espo\Core\ORM\Type\FieldType;
use Espo\Core\Utils\Metadata;
use Espo\ORM\Entity;
use Espo\ORM\Defs;
use Espo\ORM\Defs\FieldDefs;
use Espo\Core\Utils\FieldUtil;
use stdClass;
/**
* Duplicates an entity.
*/
class EntityDuplicator
{
public function __construct(
private Defs $defs,
private FieldDuplicatorFactory $fieldDuplicatorFactory,
private FieldUtil $fieldUtil,
private Metadata $metadata
) {}
public function duplicate(Entity $entity): stdClass
{
$entityType = $entity->getEntityType();
$valueMap = $entity->getValueMap();
unset($valueMap->id);
$entityDefs = $this->defs->getEntity($entityType);
foreach ($entityDefs->getFieldList() as $fieldDefs) {
$this->processField($entity, $fieldDefs, $valueMap);
}
return $valueMap;
}
private function processField(Entity $entity, FieldDefs $fieldDefs, stdClass $valueMap): void
{
$entityType = $entity->getEntityType();
$field = $fieldDefs->getName();
if ($this->toIgnoreField($entityType, $fieldDefs)) {
$attributeList = $this->fieldUtil->getAttributeList($entityType, $field);
foreach ($attributeList as $attribute) {
unset($valueMap->$attribute);
}
return;
}
if (!$this->fieldDuplicatorFactory->has($entityType, $field)) {
return;
}
$fieldDuplicator = $this->fieldDuplicatorFactory->create($entityType, $field);
$fieldValueMap = $fieldDuplicator->duplicate($entity, $field);
foreach (get_object_vars($fieldValueMap) as $attribute => $value) {
$valueMap->$attribute = $value;
}
}
private function toIgnoreField(string $entityType, FieldDefs $fieldDefs): bool
{
$type = $fieldDefs->getType();
if (in_array($type, [FieldType::AUTOINCREMENT, FieldType::NUMBER])) {
return true;
}
if ($this->metadata->get(['scopes', $entityType, 'statusField']) === $fieldDefs->getName()) {
return true;
}
return (bool) $fieldDefs->getParam('duplicateIgnore');
}
}

View File

@@ -0,0 +1,43 @@
<?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\Duplicator;
use Espo\ORM\Entity;
use stdClass;
/**
* Duplicates attributes of a field. Some fields can require some processing
* when an entity is being duplicated.
*/
interface FieldDuplicator
{
public function duplicate(Entity $entity, string $field): stdClass;
}

View File

@@ -0,0 +1,89 @@
<?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\Duplicator;
use Espo\ORM\Defs;
use Espo\Core\Utils\Metadata;
use Espo\Core\InjectableFactory;
use RuntimeException;
class FieldDuplicatorFactory
{
public function __construct(
private Defs $defs,
private Metadata $metadata,
private InjectableFactory $injectableFactory
) {}
public function create(string $entityType, string $field): FieldDuplicator
{
$className = $this->getClassName($entityType, $field);
if (!$className) {
throw new RuntimeException("No field duplicator for the field.");
}
return $this->injectableFactory->create($className);
}
public function has(string $entityType, string $field): bool
{
return $this->getClassName($entityType, $field) !== null;
}
/**
* @return ?class-string<FieldDuplicator>
*/
private function getClassName(string $entityType, string $field): ?string
{
$fieldDefs = $this->defs
->getEntity($entityType)
->getField($field);
$className1 = $fieldDefs->getParam('duplicatorClassName');
if ($className1) {
/** @var class-string<FieldDuplicator> */
return $className1;
}
$type = $fieldDefs->getType();
$className2 = $this->metadata->get(['fields', $type, 'duplicatorClassName']);
if ($className2) {
/** @var class-string<FieldDuplicator> */
return $className2;
}
return null;
}
}

View File

@@ -0,0 +1,90 @@
<?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\DynamicLogic;
use Espo\Core\Utils\FieldUtil;
use Espo\Core\Utils\Metadata;
use Espo\ORM\Entity;
use Espo\Tools\DynamicLogic\ConditionChecker;
use Espo\Tools\DynamicLogic\ConditionCheckerFactory;
use Espo\Tools\DynamicLogic\Exceptions\BadCondition;
use Espo\Tools\DynamicLogic\Item;
use RuntimeException;
use stdClass;
class InputFilterProcessor
{
public function __construct(
private Metadata $metadata,
private FieldUtil $fieldUtil,
private ConditionCheckerFactory $conditionCheckerFactory,
) {}
public function process(Entity $entity, stdClass $input): void
{
/** @var array<string, array<string, mixed>> $fieldsDefs */
$fieldsDefs = $this->metadata->get("logicDefs.{$entity->getEntityType()}.fields") ?? [];
$checker = null;
foreach ($fieldsDefs as $field => $defs) {
if ($defs['readOnlySaved'] ?? null) {
$checker ??= $this->conditionCheckerFactory->create($entity);
$this->processField($entity, $input, $field, $checker);
}
}
}
private function processField(Entity $entity, stdClass $input, string $field, ConditionChecker $checker): void
{
/** @var ?stdClass[] $group */
$group = $this->metadata
->getObjects("logicDefs.{$entity->getEntityType()}.fields.$field.readOnlySaved.conditionGroup");
if (!$group) {
return;
}
try {
$item = Item::fromGroupDefinition($group);
if (!$checker->check($item)) {
return;
}
} catch (BadCondition $e) {
throw new RuntimeException($e->getMessage(), 0, $e);
}
foreach ($this->fieldUtil->getAttributeList($entity->getEntityType(), $field) as $attribute) {
unset($input->$attribute);
}
}
}

View File

@@ -0,0 +1,105 @@
<?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\Acl;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Exceptions\NotFound;
use Espo\ORM\Entity;
use Espo\ORM\EntityManager;
/**
* Fetches entities.
*
* @since 8.1.0
*/
class EntityProvider
{
public function __construct(
private EntityManager $entityManager,
private Acl $acl
) {}
/**
* Fetch an entity.
*
* @template T of Entity
* @param class-string<T> $className An entity class name.
* @return T
* @throws NotFound A record not found.
* @throws Forbidden Read is forbidden for a current user.
* @since 8.3.0
* @noinspection PhpDocSignatureInspection
*/
public function getByClass(string $className, string $id): Entity
{
$entity = $this->entityManager
->getRDBRepositoryByClass($className)
->getById($id);
return $this->processGet($entity);
}
/**
* Fetch an entity by an entity type.
*
* @return Entity
* @throws NotFound
* @throws Forbidden
* @since 9.0.0
*/
public function get(string $entityType, string $id): Entity
{
$entity = $this->entityManager->getEntityById($entityType, $id);
return $this->processGet($entity);
}
/**
* @template T of Entity
* @param ?T $entity
* @return T
* @throws Forbidden
* @throws NotFound
* @noinspection PhpDocSignatureInspection
*/
private function processGet(?Entity $entity): Entity
{
if (!$entity) {
throw new NotFound();
}
if (!$this->acl->checkEntityRead($entity)) {
throw new Forbidden();
}
return $entity;
}
}

View File

@@ -0,0 +1,58 @@
<?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;
/**
* Immutable.
*/
class FindParams
{
private bool $noTotal = false;
public function __construct() {}
public function withNoTotal(bool $noTotal = true): self
{
$obj = clone $this;
$obj->noTotal = $noTotal;
return $obj;
}
public function noTotal(): bool
{
return $this->noTotal;
}
public static function create(): self
{
return new self();
}
}

View File

@@ -0,0 +1,49 @@
<?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\Api\Request;
class FindParamsFetcher
{
public function __construct() {}
public function fetch(Request $request): FindParams
{
$noTotal = strtolower($request->getHeader('X-No-Total') ?? '') === 'true';
if ($request->getQueryParam('q')) {
$noTotal = true;
}
return FindParams::create()
->withNoTotal($noTotal);
}
}

View File

@@ -0,0 +1,103 @@
<?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\Formula;
use Espo\Core\Formula\Exceptions\Error as FormulaError;
use Espo\Core\Formula\Manager as FormulaManager;
use Espo\Core\Record\CreateParams;
use Espo\Core\Record\UpdateParams;
use Espo\Core\Utils\Metadata;
use Espo\ORM\Entity;
use RuntimeException;
use stdClass;
/**
* Formula script processing for API requests.
*/
class Processor
{
public function __construct(
private FormulaManager $formulaManager,
private Metadata $metadata
) {}
/**
* Process a before-create formula script.
*/
public function processBeforeCreate(Entity $entity, CreateParams $params): void
{
$script = $this->getScript($entity->getEntityType());
if (!$script) {
return;
}
$variables = (object) [
'__skipDuplicateCheck' => $params->skipDuplicateCheck(),
'__isRecordService' => true,
];
$this->run($script, $entity, $variables);
}
/**
* Process a before-update formula script.
*/
public function processBeforeUpdate(Entity $entity, UpdateParams $params): void
{
$script = $this->getScript($entity->getEntityType());
if (!$script) {
return;
}
$variables = (object) [
'__skipDuplicateCheck' => $params->skipDuplicateCheck(),
'__isRecordService' => true,
];
$this->run($script, $entity, $variables);
}
private function run(string $script, Entity $entity, stdClass $variables): void
{
try {
$this->formulaManager->run($script, $entity, $variables);
} catch (FormulaError $e) {
throw new RuntimeException('Formula script error.', 500, $e);
}
}
private function getScript(string $entityType): ?string
{
/** @var ?string */
return $this->metadata->get(['formula', $entityType, 'beforeSaveApiScript']);
}
}

View File

@@ -0,0 +1,50 @@
<?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\Hook;
use Espo\Core\Exceptions\BadRequest;
use Espo\Core\Exceptions\Conflict;
use Espo\Core\Exceptions\Forbidden;
use Espo\ORM\Entity;
use Espo\Core\Record\CreateParams;
/**
* @template TEntity of Entity
*/
interface CreateHook
{
/**
* @param TEntity $entity
* @throws BadRequest
* @throws Forbidden
* @throws Conflict
*/
public function process(Entity $entity, CreateParams $params): void;
}

View File

@@ -0,0 +1,50 @@
<?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\Hook;
use Espo\Core\Exceptions\BadRequest;
use Espo\Core\Exceptions\Conflict;
use Espo\Core\Exceptions\Forbidden;
use Espo\ORM\Entity;
use Espo\Core\Record\DeleteParams;
/**
* @template TEntity of Entity
*/
interface DeleteHook
{
/**
* @param TEntity $entity
* @throws BadRequest
* @throws Forbidden
* @throws Conflict
*/
public function process(Entity $entity, DeleteParams $params): void;
}

View File

@@ -0,0 +1,43 @@
<?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\Hook;
use Espo\ORM\Entity;
/**
* @template TEntity of Entity
*/
interface LinkHook
{
/**
* @param TEntity $entity
*/
public function process(Entity $entity, string $link, Entity $foreignEntity): void;
}

View File

@@ -0,0 +1,132 @@
<?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\Hook;
use Espo\Core\Acl;
use Espo\Core\Binding\BindingContainer;
use Espo\Core\Binding\BindingContainerBuilder;
use Espo\Core\Utils\Metadata;
use Espo\Core\InjectableFactory;
use Espo\Entities\User;
use ReflectionClass;
use RuntimeException;
class Provider
{
/** @var array<string, object[]> */
private $map = [];
/** @var array<string, class-string[]> */
private $typeInterfaceListMap = [
Type::BEFORE_READ => [ReadHook::class],
Type::EARLY_BEFORE_CREATE => [CreateHook::class, SaveHook::class],
Type::BEFORE_CREATE => [CreateHook::class, SaveHook::class],
Type::AFTER_CREATE => [CreateHook::class, SaveHook::class],
Type::EARLY_BEFORE_UPDATE => [UpdateHook::class, SaveHook::class],
Type::BEFORE_UPDATE => [UpdateHook::class, SaveHook::class],
Type::AFTER_UPDATE => [UpdateHook::class, SaveHook::class],
Type::BEFORE_DELETE => [DeleteHook::class],
Type::AFTER_DELETE => [DeleteHook::class],
Type::BEFORE_LINK => [LinkHook::class],
Type::BEFORE_UNLINK => [UnlinkHook::class],
Type::AFTER_LINK => [LinkHook::class],
Type::AFTER_UNLINK => [UnlinkHook::class],
];
private BindingContainer $bindingContainer;
public function __construct(
private Metadata $metadata,
private InjectableFactory $injectableFactory,
private Acl $acl,
private User $user
) {
$this->bindingContainer = BindingContainerBuilder::create()
->bindInstance(User::class, $this->user)
->bindInstance(Acl::class, $this->acl)
->build();
}
/**
* @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';
/** @var class-string[] $classNameList */
$classNameList = $this->metadata->get(['recordDefs', $entityType, $key]) ?? [];
$interfaces = $this->typeInterfaceListMap[$type] ?? null;
if (!$interfaces) {
throw new RuntimeException("Unsupported record hook type '$type'.");
}
$list = [];
foreach ($classNameList as $className) {
$class = new ReflectionClass($className);
$found = false;
foreach ($interfaces as $interface) {
if ($class->implementsInterface($interface)) {
$found = true;
break;
}
}
if (!$found) {
throw new RuntimeException("Hook '$className' does not implement any required interface.");
}
$list[] = $this->injectableFactory->createWithBinding($className, $this->bindingContainer);
}
return $list;
}
}

View File

@@ -0,0 +1,44 @@
<?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\Hook;
use Espo\ORM\Entity;
use Espo\Core\Record\ReadParams;
/**
* @template TEntity of Entity
*/
interface ReadHook
{
/**
* @param TEntity $entity
*/
public function process(Entity $entity, ReadParams $params): void;
}

View File

@@ -0,0 +1,52 @@
<?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\Hook;
use Espo\Core\Exceptions\BadRequest;
use Espo\Core\Exceptions\Conflict;
use Espo\Core\Exceptions\Forbidden;
use Espo\ORM\Entity;
/**
* On record create or update.
*
* @template TEntity of Entity
* @since 8.1.0.
*/
interface SaveHook
{
/**
* @param TEntity $entity
* @throws BadRequest
* @throws Forbidden
* @throws Conflict
*/
public function process(Entity $entity): void;
}

View File

@@ -0,0 +1,51 @@
<?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\Hook;
class Type
{
public const BEFORE_READ = 'beforeRead';
public const EARLY_BEFORE_CREATE = 'earlyBeforeCreate';
public const EARLY_BEFORE_UPDATE = 'earlyBeforeUpdate';
public const BEFORE_CREATE = 'beforeCreate';
public const BEFORE_UPDATE = 'beforeUpdate';
public const BEFORE_DELETE = 'beforeDelete';
public const AFTER_CREATE = 'afterCreate';
public const AFTER_UPDATE = 'afterUpdate';
public const AFTER_DELETE = 'afterDelete';
public const BEFORE_LINK = 'beforeLink';
public const BEFORE_UNLINK = 'beforeUnlink';
public const AFTER_LINK = 'afterLink';
public const AFTER_UNLINK = 'afterUnlink';
}

View File

@@ -0,0 +1,43 @@
<?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\Hook;
use Espo\ORM\Entity;
/**
* @template TEntity of Entity
*/
interface UnlinkHook
{
/**
* @param TEntity $entity
*/
public function process(Entity $entity, string $link, Entity $foreignEntity): void;
}

View File

@@ -0,0 +1,51 @@
<?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\Hook;
use Espo\Core\Exceptions\BadRequest;
use Espo\Core\Exceptions\Conflict;
use Espo\Core\Exceptions\Forbidden;
use Espo\ORM\Entity;
use Espo\Core\Record\UpdateParams;
/**
* @template TEntity of Entity
*/
interface UpdateHook
{
/**
* @param TEntity $entity
* @throws BadRequest
* @throws Forbidden
* @throws Conflict
*/
public function process(Entity $entity, UpdateParams $params): void;
}

View File

@@ -0,0 +1,334 @@
<?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\Exceptions\BadRequest;
use Espo\Core\Exceptions\Conflict;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Record\Hook\CreateHook;
use Espo\Core\Record\Hook\DeleteHook;
use Espo\Core\Record\Hook\LinkHook;
use Espo\Core\Record\Hook\ReadHook;
use Espo\Core\Record\Hook\SaveHook;
use Espo\Core\Record\Hook\UnlinkHook;
use Espo\Core\Record\Hook\UpdateHook;
use Espo\Core\Record\Hook\Provider;
use Espo\Core\Record\Hook\Type;
use Espo\ORM\Entity;
class HookManager
{
public function __construct(private Provider $provider)
{}
/**
* @throws BadRequest
* @throws Forbidden
* @throws Conflict
*/
public function processEarlyBeforeCreate(Entity $entity, CreateParams $params): void
{
foreach ($this->getEarlyBeforeCreateHookList($entity->getEntityType()) as $hook) {
if ($hook instanceof SaveHook) {
$hook->process($entity);
continue;
}
$hook->process($entity, $params);
}
}
/**
* @throws BadRequest
* @throws Forbidden
* @throws Conflict
*/
public function processBeforeCreate(Entity $entity, CreateParams $params): void
{
foreach ($this->getBeforeCreateHookList($entity->getEntityType()) as $hook) {
if ($hook instanceof SaveHook) {
$hook->process($entity);
continue;
}
$hook->process($entity, $params);
}
}
/**
* @throws BadRequest
* @throws Forbidden
* @throws Conflict
*/
public function processAfterCreate(Entity $entity, CreateParams $params): void
{
foreach ($this->getAfterCreateHookList($entity->getEntityType()) as $hook) {
if ($hook instanceof SaveHook) {
$hook->process($entity);
continue;
}
$hook->process($entity, $params);
}
}
public function processBeforeRead(Entity $entity, ReadParams $params): void
{
foreach ($this->getBeforeReadHookList($entity->getEntityType()) as $hook) {
$hook->process($entity, $params);
}
}
/**
* @throws BadRequest
* @throws Forbidden
* @throws Conflict
*/
public function processEarlyBeforeUpdate(Entity $entity, UpdateParams $params): void
{
foreach ($this->getEarlyBeforeUpdateHookList($entity->getEntityType()) as $hook) {
if ($hook instanceof SaveHook) {
$hook->process($entity);
continue;
}
$hook->process($entity, $params);
}
}
/**
* @throws BadRequest
* @throws Forbidden
* @throws Conflict
*/
public function processBeforeUpdate(Entity $entity, UpdateParams $params): void
{
foreach ($this->getBeforeUpdateHookList($entity->getEntityType()) as $hook) {
if ($hook instanceof SaveHook) {
$hook->process($entity);
continue;
}
$hook->process($entity, $params);
}
}
/**
* @throws BadRequest
* @throws Forbidden
* @throws Conflict
*/
public function processAfterUpdate(Entity $entity, UpdateParams $params): void
{
foreach ($this->getAfterUpdateHookList($entity->getEntityType()) as $hook) {
if ($hook instanceof SaveHook) {
$hook->process($entity);
continue;
}
$hook->process($entity, $params);
}
}
/**
* @throws BadRequest
* @throws Forbidden
* @throws Conflict
*/
public function processBeforeDelete(Entity $entity, DeleteParams $params): void
{
foreach ($this->getBeforeDeleteHookList($entity->getEntityType()) as $hook) {
$hook->process($entity, $params);
}
}
/**
* @throws BadRequest
* @throws Forbidden
* @throws Conflict
*/
public function processAfterDelete(Entity $entity, DeleteParams $params): void
{
foreach ($this->getAfterDeleteHookList($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);
}
}
public function processAfterLink(Entity $entity, string $link, Entity $foreignEntity): void
{
foreach ($this->getAfterLinkHookList($entity->getEntityType()) as $hook) {
$hook->process($entity, $link, $foreignEntity);
}
}
public function processAfterUnlink(Entity $entity, string $link, Entity $foreignEntity): void
{
foreach ($this->getAfterUnlinkHookList($entity->getEntityType()) as $hook) {
$hook->process($entity, $link, $foreignEntity);
}
}
/**
* @return ReadHook<Entity>[]
*/
private function getBeforeReadHookList(string $entityType): array
{
/** @var ReadHook<Entity>[] */
return $this->provider->getList($entityType, Type::BEFORE_READ);
}
/**
* @return (CreateHook<Entity>|SaveHook<Entity>)[]
*/
private function getEarlyBeforeCreateHookList(string $entityType): array
{
/** @var (CreateHook<Entity>|SaveHook<Entity>)[] */
return $this->provider->getList($entityType, Type::EARLY_BEFORE_CREATE);
}
/**
* @return (CreateHook<Entity>|SaveHook<Entity>)[]
*/
private function getBeforeCreateHookList(string $entityType): array
{
/** @var (CreateHook<Entity>|SaveHook<Entity>)[] */
return $this->provider->getList($entityType, Type::BEFORE_CREATE);
}
/**
* @return (CreateHook<Entity>|SaveHook<Entity>)[]
*/
private function getAfterCreateHookList(string $entityType): array
{
/** @var (CreateHook<Entity>|SaveHook<Entity>)[] */
return $this->provider->getList($entityType, Type::AFTER_CREATE);
}
/**
* @return (UpdateHook<Entity>|SaveHook<Entity>)[]
*/
private function getEarlyBeforeUpdateHookList(string $entityType): array
{
/** @var (UpdateHook<Entity>|SaveHook<Entity>)[] */
return $this->provider->getList($entityType, Type::EARLY_BEFORE_UPDATE);
}
/**
* @return (UpdateHook<Entity>|SaveHook<Entity>)[]
*/
private function getBeforeUpdateHookList(string $entityType): array
{
/** @var (UpdateHook<Entity>|SaveHook<Entity>)[] */
return $this->provider->getList($entityType, Type::BEFORE_UPDATE);
}
/**
* @return (UpdateHook<Entity>|SaveHook<Entity>)[]
*/
private function getAfterUpdateHookList(string $entityType): array
{
/** @var (UpdateHook<Entity>|SaveHook<Entity>)[] */
return $this->provider->getList($entityType, Type::AFTER_UPDATE);
}
/**
* @return DeleteHook<Entity>[]
*/
private function getBeforeDeleteHookList(string $entityType): array
{
/** @var DeleteHook<Entity>[] */
return $this->provider->getList($entityType, Type::BEFORE_DELETE);
}
/**
* @return DeleteHook<Entity>[]
*/
private function getAfterDeleteHookList(string $entityType): array
{
/** @var DeleteHook<Entity>[] */
return $this->provider->getList($entityType, Type::AFTER_DELETE);
}
/**
* @return LinkHook<Entity>[]
*/
private function getBeforeLinkHookList(string $entityType): array
{
/** @var LinkHook<Entity>[] */
return $this->provider->getList($entityType, Type::BEFORE_LINK);
}
/**
* @return UnlinkHook<Entity>[]
*/
private function getBeforeUnlinkHookList(string $entityType): array
{
/** @var UnlinkHook<Entity>[] */
return $this->provider->getList($entityType, Type::BEFORE_UNLINK);
}
/**
* @return LinkHook<Entity>[]
*/
private function getAfterLinkHookList(string $entityType): array
{
/** @var LinkHook<Entity>[] */
return $this->provider->getList($entityType, Type::AFTER_LINK);
}
/**
* @return UnlinkHook<Entity>[]
*/
private function getAfterUnlinkHookList(string $entityType): array
{
/** @var UnlinkHook<Entity>[] */
return $this->provider->getList($entityType, Type::AFTER_UNLINK);
}
}

View File

@@ -0,0 +1,90 @@
<?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\Input;
use stdClass;
class Data
{
public function __construct(private stdClass $raw) {}
/**
* Get all attributes.
*
* @return string[]
*/
public function getAttributeList(): array
{
return array_keys(get_object_vars($this->raw));
}
/**
* Unset an attribute.
*
* @param string $name An attribute name.
*/
public function clear(string $name): self
{
unset($this->raw->$name);
return $this;
}
/**
* Whether an attribute is set.
*
* @param string $name An attribute name.
*/
public function has(string $name): bool
{
return property_exists($this->raw, $name);
}
/**
* Get an attribute value.
*
* @param string $name An attribute name.
*/
public function get(string $name): mixed
{
return $this->raw->$name ?? null;
}
/**
* Set an attribute value.
*
* @param string $name An attribute name.
* @param mixed $value A value
*/
public function set(string $name, mixed $value): mixed
{
return $this->raw->$name = $value;
}
}

View File

@@ -0,0 +1,38 @@
<?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\Input;
/**
* An input filter.
*/
interface Filter
{
public function filter(Data $data): void;
}

View File

@@ -0,0 +1,100 @@
<?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\Input;
use Espo\Core\Acl;
use Espo\Core\Binding\BindingContainerBuilder;
use Espo\Core\InjectableFactory;
use Espo\Core\Utils\Metadata;
use Espo\Entities\User;
class FilterProvider
{
public function __construct(
private InjectableFactory $injectableFactory,
private Metadata $metadata,
private Acl $acl,
private User $user
) {}
/**
* @return Filter[]
*/
public function getForCreate(string $entityType): array
{
$classNameList = $this->getCreateClassNameList($entityType);
$binding = BindingContainerBuilder::create()
->bindInstance(User::class, $this->user)
->bindInstance(Acl::class, $this->acl)
->build();
return array_map(
fn ($className) => $this->injectableFactory->createWithBinding($className, $binding),
$classNameList
);
}
/**
* @return Filter[]
*/
public function getForUpdate(string $entityType): array
{
$classNameList = $this->getUpdateClassNameList($entityType);
$binding = BindingContainerBuilder::create()
->bindInstance(User::class, $this->user)
->bindInstance(Acl::class, $this->acl)
->build();
return array_map(
fn ($className) => $this->injectableFactory->createWithBinding($className, $binding),
$classNameList
);
}
/**
* @return class-string<Filter>[]
*/
private function getCreateClassNameList(string $entityType): array
{
/** @var class-string<Filter>[] */
return $this->metadata->get("recordDefs.$entityType.createInputFilterClassNameList") ?? [];
}
/**
* @return class-string<Filter>[]
*/
private function getUpdateClassNameList(string $entityType): array
{
/** @var class-string<Filter>[] */
return $this->metadata->get("recordDefs.$entityType.updateInputFilterClassNameList") ?? [];
}
}

View File

@@ -0,0 +1,45 @@
<?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\Output;
use Espo\ORM\Entity;
/**
* Filters entity attribute values for output.
*
* @template TEntity of Entity
*/
interface Filter
{
/**
* @param TEntity $entity
*/
public function filter(Entity $entity): void;
}

View File

@@ -0,0 +1,74 @@
<?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\Output;
use Espo\Core\Acl;
use Espo\Core\Binding\BindingContainerBuilder;
use Espo\Core\InjectableFactory;
use Espo\Core\Utils\Metadata;
use Espo\Entities\User;
use Espo\ORM\Entity;
class FilterProvider
{
public function __construct(
private InjectableFactory $injectableFactory,
private Metadata $metadata,
private Acl $acl,
private User $user
) {}
/**
* @return Filter<Entity>[]
*/
public function get(string $entityType): array
{
$classNameList = $this->getClassNameList($entityType);
$binding = BindingContainerBuilder::create()
->bindInstance(User::class, $this->user)
->bindInstance(Acl::class, $this->acl)
->build();
return array_map(
fn ($className) => $this->injectableFactory->createWithBinding($className, $binding),
$classNameList
);
}
/**
* @return class-string<Filter<Entity>>[]
*/
private function getClassNameList(string $entityType): array
{
/** @var class-string<Filter<Entity>>[] */
return $this->metadata->get("recordDefs.$entityType.outputFilterClassNameList") ?? [];
}
}

View File

@@ -0,0 +1,43 @@
<?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;
/**
* Immutable.
*/
class ReadParams
{
public function __construct() {}
public static function create(): self
{
return new self();
}
}

View File

@@ -0,0 +1,42 @@
<?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\Api\Request;
class ReadParamsFetcher
{
public function __construct() {}
public function fetch(Request $request): ReadParams
{
return ReadParams::create();
}
}

View File

@@ -0,0 +1,255 @@
<?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\Exceptions\Forbidden;
use Espo\Core\Exceptions\BadRequest;
use Espo\Core\Utils\Config;
use Espo\Core\Api\Request;
use Espo\Core\Select\SearchParams;
use Espo\Core\Select\Text\MetadataProvider as TextMetadataProvider;
use Espo\Core\Utils\Json;
use InvalidArgumentException;
use JsonException;
class SearchParamsFetcher
{
private const MAX_SIZE_LIMIT = 200;
public function __construct(
private Config $config,
private TextMetadataProvider $textMetadataProvider
) {}
/**
* Fetch search params from a request.
*
* @throws BadRequest
* @throws Forbidden
*/
public function fetch(Request $request): SearchParams
{
try {
return SearchParams::fromRaw($this->fetchRaw($request));
} catch (InvalidArgumentException $e) {
throw new BadRequest($e->getMessage());
}
}
/**
* @return array<string, mixed>
* @throws BadRequest
* @throws Forbidden
*/
private function fetchRaw(Request $request): array
{
$params = $request->hasQueryParam('searchParams') ?
$this->fetchRawJsonSearchParams($request):
$this->fetchRawMultipleParams($request);
$this->handleRawParams($params, $request);
return $params;
}
/**
* @return array<string, mixed>
* @throws BadRequest
*/
private function fetchRawJsonSearchParams(Request $request): array
{
try {
return Json::decode($request->getQueryParam('searchParams') ?? '', true);
} catch (JsonException) {
throw new BadRequest("Invalid search params JSON.");
}
}
/**
* @return array<string, mixed>
*/
private function fetchRawMultipleParams(Request $request): array
{
$params = [];
$queryParams = $request->getQueryParams();
$params['where'] = $queryParams['whereGroup'] ?? $queryParams['where'] ?? null;
if ($params['where'] !== null && !is_array($params['where'])) {
$params['where'] = null;
}
$params['maxSize'] = $request->getQueryParam('maxSize');
$params['offset'] = $request->getQueryParam('offset');
if ($params['maxSize'] === '') {
$params['maxSize'] = null;
}
if ($params['offset'] === '') {
$params['offset'] = null;
}
if ($params['maxSize'] !== null) {
$params['maxSize'] = intval($params['maxSize']);
}
if ($params['offset'] !== null) {
$params['offset'] = intval($params['offset']);
}
if ($request->getQueryParam('orderBy')) {
$params['orderBy'] = $request->getQueryParam('orderBy');
} else if ($request->getQueryParam('sortBy')) {
// legacy
$params['orderBy'] = $request->getQueryParam('sortBy');
}
if ($request->getQueryParam('order')) {
$params['order'] = strtoupper($request->getQueryParam('order'));
} else if ($request->getQueryParam('asc')) {
// legacy
$params['order'] = $request->getQueryParam('asc') === 'true' ?
SearchParams::ORDER_ASC : SearchParams::ORDER_DESC;
}
$q = $request->getQueryParam('q');
if ($q) {
$params['q'] = trim($q);
}
if ($request->getQueryParam('textFilter')) {
$params['textFilter'] = $request->getQueryParam('textFilter');
}
if ($request->getQueryParam('primaryFilter')) {
$params['primaryFilter'] = $request->getQueryParam('primaryFilter');
}
if ($queryParams['boolFilterList'] ?? null) {
$params['boolFilterList'] = (array) $queryParams['boolFilterList'];
}
if ($queryParams['filterList'] ?? null) {
$params['filterList'] = (array) $queryParams['filterList'];
}
$select = $request->getQueryParam('attributeSelect') ?? $request->getQueryParam('select');
if ($select) {
$params['select'] = explode(',', $select);
}
return $params;
}
/**
* @param array<string, mixed> $params
* @throws BadRequest
* @throws Forbidden
*/
private function handleRawParams(array &$params, Request $request): void
{
if (isset($params['maxSize']) && !is_int($params['maxSize'])) {
throw new BadRequest('maxSize must be integer.');
}
$this->handleQ($params, $request);
$this->handleMaxSize($params);
}
private function hasFullTextSearch(Request $request): bool
{
$scope = $request->getRouteParam('controller');
if (!$scope) {
return false;
}
if ($request->getRouteParam('action') !== 'index') {
return false;
}
return $this->textMetadataProvider->hasFullTextSearch($scope);
}
/**
* @param array<string, mixed> $params
* @throws Forbidden
*/
private function handleMaxSize(array &$params): void
{
$value = $params['maxSize'] ?? null;
$limit = $this->config->get('recordListMaxSizeLimit') ?? self::MAX_SIZE_LIMIT;
if ($value === null) {
$params['maxSize'] = $limit;
}
if ($value > $limit) {
throw new Forbidden("Max size should not exceed $limit. Use offset and limit.");
}
}
/**
* @param array<string, mixed> $params
* @throws BadRequest
*/
private function handleQ(array &$params, Request $request): void
{
$q = $params['q'] ?? null;
if ($q === null) {
return;
}
if (!is_string($q)) {
throw new BadRequest("q must be string.");
}
if (!$this->config->get('quickSearchFullTextAppendWildcard')) {
return;
}
if (
!str_contains($q, '*') &&
!str_contains($q, '"') &&
!str_contains($q, '+') &&
!str_contains($q, '-') &&
$this->hasFullTextSearch($request)
) {
$params['q'] = $q . '*';
}
}
}

View File

@@ -0,0 +1,50 @@
<?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\Select;
use Espo\Core\Select\Applier\AdditionalApplier;
use Espo\Core\Utils\Metadata;
class ApplierClassNameListProvider
{
public function __construct(private Metadata $metadata)
{}
/**
* @return class-string<AdditionalApplier>[]
*/
public function get(string $entityType): array
{
return [
...$this->metadata->get("app.record.selectApplierClassNameList", []),
...$this->metadata->get("recordDefs.$entityType.selectApplierClassNameList", []),
];
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,84 @@
<?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\ORM\Entity;
use Espo\ORM\Repository\Util as RepositoryUtil;
/**
* Container for record services. Lazy loading is used.
* Usually there's no need to have multiple record service instances of the same entity type.
* Use this container instead of serviceFactory to get record services.
*
* Important. Returns record services for the current user.
* Use the service-factory to create services for a specific user.
*/
class ServiceContainer
{
/** @var array<string, Service<Entity>> */
private $data = [];
public function __construct(private ServiceFactory $serviceFactory)
{}
/**
* Get a record service by an entity class name.
*
* @template T of Entity
* @param class-string<T> $className An entity class name.
* @return Service<T>
*/
public function getByClass(string $className): Service
{
$entityType = RepositoryUtil::getEntityTypeByClass($className);
/** @var Service<T> */
return $this->get($entityType);
}
/**
* Get a record service by an entity type.
*
* @return Service<Entity>
*/
public function get(string $entityType): Service
{
if (!array_key_exists($entityType, $this->data)) {
$this->load($entityType);
}
return $this->data[$entityType];
}
private function load(string $entityType): void
{
$this->data[$entityType] = $this->serviceFactory->create($entityType);
}
}

View File

@@ -0,0 +1,168 @@
<?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\ServiceFactory as Factory;
use Espo\Core\Utils\Metadata;
use Espo\Entities\User;
use Espo\Core\Acl;
use Espo\Core\AclManager;
use Espo\ORM\Entity;
use Espo\ORM\Repository\Util as RepositoryUtil;
use RuntimeException;
/**
* Create a service for a specific user.
*/
class ServiceFactory
{
private const RECORD_SERVICE_NAME = 'Record';
private const RECORD_TREE_SERVICE_NAME = 'RecordTree';
/** @var array<string, string> */
private $defaultTypeMap = [
'CategoryTree' => self::RECORD_TREE_SERVICE_NAME,
];
public function __construct(
private Factory $serviceFactory,
private Metadata $metadata,
private User $user,
private Acl $acl,
private AclManager $aclManager
) {}
/**
* Create a record service by an entity class name.
*
* @template T of Entity
* @param class-string<T> $className An entity class name.
* @return Service<T>
*/
public function createByClass(string $className): Service
{
$entityType = RepositoryUtil::getEntityTypeByClass($className);
/** @var Service<T> */
return $this->create($entityType);
}
/**
* Create a record service for a user by an entity class name.
*
* @template T of Entity
* @param class-string<T> $className An entity class name.
* @return Service<T>
*/
public function createByClassForUser(string $className, User $user): Service
{
$entityType = RepositoryUtil::getEntityTypeByClass($className);
/** @var Service<T> */
return $this->createForUser($entityType, $user);
}
/**
* Create a record service by an entity type.
*
* @return Service<Entity>
*/
public function create(string $entityType): Service
{
$obj = $this->createInternal($entityType);
$obj->setUser($this->user);
$obj->setAcl($this->acl);
return $obj;
}
/**
* Create a record service for a user.
*
* @return Service<Entity>
*/
public function createForUser(string $entityType, User $user): Service
{
$obj = $this->createInternal($entityType);
$acl = $this->aclManager->createUserAcl($user);
$obj->setUser($user);
$obj->setAcl($acl);
return $obj;
}
/**
* @return Service<Entity>
*/
private function createInternal(string $entityType): Service
{
if (!$this->metadata->get(['scopes', $entityType, 'entity'])) {
throw new RuntimeException("Can't create record service '{$entityType}', there's no such entity type.");
}
if (!$this->serviceFactory->checkExists($entityType)) {
return $this->createDefault($entityType);
}
$service = $this->serviceFactory->createWith($entityType, ['entityType' => $entityType]);
if (!$service instanceof Service) {
return $this->createDefault($entityType);
}
return $service;
}
/**
* @return Service<Entity>
*/
private function createDefault(string $entityType): Service
{
$default = self::RECORD_SERVICE_NAME;
$type = $this->metadata->get(['scopes', $entityType, 'type']);
if ($type) {
$default = $this->defaultTypeMap[$type] ?? $default;
}
$obj = $this->serviceFactory->createWith($default, ['entityType' => $entityType]);
if (!$obj instanceof Service) {
throw new RuntimeException("Service class {$default} is not instance of Record.");
}
return $obj;
}
}

View File

@@ -0,0 +1,41 @@
<?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;
/**
* @internal
* @todo Remove in v10.0. Use UpdateResult.
*/
class UpdateContext
{
public function __construct(
public bool $linkUpdated = false,
) {}
}

View File

@@ -0,0 +1,95 @@
<?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;
/**
* Immutable.
*/
class UpdateParams
{
private bool $skipDuplicateCheck = false;
private ?int $versionNumber = null;
private ?UpdateContext $context = null;
public function __construct() {}
public function withSkipDuplicateCheck(bool $skipDuplicateCheck = true): self
{
$obj = clone $this;
$obj->skipDuplicateCheck = $skipDuplicateCheck;
return $obj;
}
public function withVersionNumber(?int $versionNumber): self
{
$obj = clone $this;
$obj->versionNumber = $versionNumber;
return $obj;
}
public function skipDuplicateCheck(): bool
{
return $this->skipDuplicateCheck;
}
public function getVersionNumber(): ?int
{
return $this->versionNumber;
}
public static function create(): self
{
return new self();
}
/**
* @internal
* @todo Remove in v10.0.
*/
public function withContext(?UpdateContext $context): self
{
$obj = clone $this;
$obj->context = $context;
return $obj;
}
/**
* @internal
* @todo Remove in v10.0.
*/
public function getContext(): ?UpdateContext
{
return $this->context;
}
}

View File

@@ -0,0 +1,57 @@
<?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\Api\Request;
class UpdateParamsFetcher
{
public function __construct() {}
public function fetch(Request $request): UpdateParams
{
$data = $request->getParsedBody();
$skipDuplicateCheck = $request->hasHeader('X-Skip-Duplicate-Check') ?
strtolower($request->getHeader('X-Skip-Duplicate-Check') ?? '') === 'true' :
$data->_skipDuplicateCheck ?? // legacy
false;
$versionNumber = $request->getHeader('X-Version-Number');
if ($versionNumber !== null) {
$versionNumber = intval($versionNumber);
}
return UpdateParams::create()
->withSkipDuplicateCheck($skipDuplicateCheck)
->withVersionNumber($versionNumber);
}
}