From 4418133ec0d4afc281e4958cbdeee0dc9a86d443 Mon Sep 17 00:00:00 2001 From: Yuri Kuznetsov Date: Thu, 1 May 2025 17:22:13 +0300 Subject: [PATCH] entity defs field loaders --- .../FieldProcessing/ListLoadProcessor.php | 58 ++++++++++++++-- .../FieldProcessing/ReadLoadProcessor.php | 34 +++++++++- .../FieldProcessing/SpecificFieldLoader.php | 68 +++++++++++++++++++ .../Espo/Core/Formula/AttributeFetcher.php | 16 ++++- application/Espo/Core/Utils/FieldUtil.php | 16 +++++ .../Resources/metadata/entityDefs/Email.json | 15 ++-- .../Resources/metadata/entityDefs/Portal.json | 3 +- .../Resources/metadata/recordDefs/Portal.json | 6 -- schema/metadata/entityDefs.json | 4 ++ .../unit/Espo/Core/Formula/EvaluatorTest.php | 10 ++- tests/unit/Espo/Core/Formula/FormulaTest.php | 7 +- 11 files changed, 213 insertions(+), 24 deletions(-) create mode 100644 application/Espo/Core/FieldProcessing/SpecificFieldLoader.php diff --git a/application/Espo/Core/FieldProcessing/ListLoadProcessor.php b/application/Espo/Core/FieldProcessing/ListLoadProcessor.php index fc0135f417..2ea9d492d1 100644 --- a/application/Espo/Core/FieldProcessing/ListLoadProcessor.php +++ b/application/Espo/Core/FieldProcessing/ListLoadProcessor.php @@ -32,7 +32,9 @@ namespace Espo\Core\FieldProcessing; use Espo\Core\Acl; use Espo\Core\Binding\BindingContainer; use Espo\Core\Binding\BindingContainerBuilder; +use Espo\Core\Utils\FieldUtil; use Espo\Entities\User; +use Espo\ORM\Defs; use Espo\ORM\Entity; use Espo\Core\FieldProcessing\Loader\Params; @@ -53,7 +55,9 @@ class ListLoadProcessor private InjectableFactory $injectableFactory, private Metadata $metadata, private Acl $acl, - private User $user + private User $user, + private Defs $defs, + private FieldUtil $fieldUtil, ) { $this->bindingContainer = BindingContainerBuilder::create() ->bindInstance(User::class, $this->user) @@ -67,7 +71,7 @@ class ListLoadProcessor $params = new Params(); } - foreach ($this->getLoaderList($entity->getEntityType()) as $processor) { + foreach ($this->getLoaderList($entity->getEntityType(), $params) as $processor) { $processor->process($entity, $params); } } @@ -75,7 +79,7 @@ class ListLoadProcessor /** * @return Loader[] */ - private function getLoaderList(string $entityType): array + private function getLoaderList(string $entityType, Params $params): array { if (array_key_exists($entityType, $this->loaderListMapCache)) { return $this->loaderListMapCache[$entityType]; @@ -83,7 +87,7 @@ class ListLoadProcessor $list = []; - foreach ($this->getLoaderClassNameList($entityType) as $className) { + foreach ($this->getLoaderClassNameList($entityType, $params) as $className) { $list[] = $this->createLoader($className); } @@ -95,15 +99,19 @@ class ListLoadProcessor /** * @return class-string>[] */ - private function getLoaderClassNameList(string $entityType): array + private function getLoaderClassNameList(string $entityType, Params $params): array { + $entityLevelList = $this->getEntityLevelClassNameList($entityType, $params); + $list = $this->metadata ->get(['app', 'fieldProcessing', 'listLoaderClassNameList']) ?? []; $additionalList = $this->metadata ->get(['recordDefs', $entityType, 'listLoaderClassNameList']) ?? []; - return array_merge($list, $additionalList); + $list = array_merge($entityLevelList, $list, $additionalList); + + return array_values(array_unique($list)); } /** @@ -114,4 +122,42 @@ class ListLoadProcessor { return $this->injectableFactory->createWithBinding($className, $this->bindingContainer); } + + /** + * @return class-string>[] + */ + private function getEntityLevelClassNameList(string $entityType, Params $params): array + { + $entityLevelList = []; + + $fieldList = $this->defs->getEntity($entityType)->getFieldList(); + + foreach ($fieldList as $fieldDefs) { + $className = $fieldDefs->getParam('loaderClassName'); + + if (!$className || in_array($className, $entityLevelList)) { + continue; + } + + if ($params->hasSelect()) { + $hasAttribute = false; + + foreach ($this->fieldUtil->getAttributeList($entityType, $fieldDefs->getName()) as $attribute) { + if ($params->hasInSelect($attribute)) { + $hasAttribute = true; + + break; + } + } + + if (!$hasAttribute) { + continue; + } + } + + $entityLevelList[] = $className; + } + + return $entityLevelList; + } } diff --git a/application/Espo/Core/FieldProcessing/ReadLoadProcessor.php b/application/Espo/Core/FieldProcessing/ReadLoadProcessor.php index b40a58ec1b..f0a799fe99 100644 --- a/application/Espo/Core/FieldProcessing/ReadLoadProcessor.php +++ b/application/Espo/Core/FieldProcessing/ReadLoadProcessor.php @@ -33,6 +33,7 @@ use Espo\Core\Acl; use Espo\Core\Binding\BindingContainer; use Espo\Core\Binding\BindingContainerBuilder; use Espo\Entities\User; +use Espo\ORM\Defs; use Espo\ORM\Entity; use Espo\Core\FieldProcessing\Loader\Params; @@ -53,7 +54,8 @@ class ReadLoadProcessor private InjectableFactory $injectableFactory, private Metadata $metadata, private Acl $acl, - private User $user + private User $user, + private Defs $defs, ) { $this->bindingContainer = BindingContainerBuilder::create() ->bindInstance(User::class, $this->user) @@ -97,14 +99,18 @@ class ReadLoadProcessor */ private function getLoaderClassNameList(string $entityType): array { + $entityLevelList = $this->getEntityLevelClassNameList($entityType); + $list = $this->metadata ->get(['app', 'fieldProcessing', 'readLoaderClassNameList']) ?? []; $additionalList = $this->metadata ->get(['recordDefs', $entityType, 'readLoaderClassNameList']) ?? []; - /** @var class-string>[] */ - return array_merge($list, $additionalList); + /** @var class-string>[] $list */ + $list = array_merge($entityLevelList, $list, $additionalList); + + return array_values(array_unique($list)); } /** @@ -115,4 +121,26 @@ class ReadLoadProcessor { return $this->injectableFactory->createWithBinding($className, $this->bindingContainer); } + + /** + * @return class-string>[] + */ + private function getEntityLevelClassNameList(string $entityType): array + { + $entityLevelList = []; + + $fieldList = $this->defs->getEntity($entityType)->getFieldList(); + + foreach ($fieldList as $fieldDefs) { + $className = $fieldDefs->getParam('loaderClassName'); + + if (!$className || in_array($className, $entityLevelList)) { + continue; + } + + $entityLevelList[] = $className; + } + + return $entityLevelList; + } } diff --git a/application/Espo/Core/FieldProcessing/SpecificFieldLoader.php b/application/Espo/Core/FieldProcessing/SpecificFieldLoader.php new file mode 100644 index 0000000000..4cacf7c3b6 --- /dev/null +++ b/application/Espo/Core/FieldProcessing/SpecificFieldLoader.php @@ -0,0 +1,68 @@ +. + * + * 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\FieldProcessing; + +use Espo\Core\InjectableFactory; +use Espo\ORM\Defs; +use Espo\ORM\Entity; +use RuntimeException; + +/** + * @since 9.1.0 + * @internal Yet experimental. + */ +class SpecificFieldLoader +{ + public function __construct( + private Defs $defs, + private InjectableFactory $injectableFactory, + ) {} + + public function process(Entity $entity, string $field): void + { + /** @var ?class-string> $loaderClassName */ + $loaderClassName = $this->defs + ->getEntity($entity->getEntityType()) + ->tryGetField($field) + ?->getParam('loaderClassName'); + + if (!$loaderClassName) { + return; + } + + $loader = $this->injectableFactory->create($loaderClassName); + + if (!$loader instanceof Loader) { + throw new RuntimeException("Bad field loader."); + } + + $loader->process($entity, Loader\Params::create()); + } +} diff --git a/application/Espo/Core/Formula/AttributeFetcher.php b/application/Espo/Core/Formula/AttributeFetcher.php index ed89ef8114..2edb057ab4 100644 --- a/application/Espo/Core/Formula/AttributeFetcher.php +++ b/application/Espo/Core/Formula/AttributeFetcher.php @@ -29,7 +29,9 @@ namespace Espo\Core\Formula; +use Espo\Core\FieldProcessing\SpecificFieldLoader; use Espo\Core\ORM\Defs\AttributeParam; +use Espo\Core\Utils\FieldUtil; use Espo\Entities\EmailAddress; use Espo\Entities\PhoneNumber; use Espo\ORM\Defs\Params\AttributeParam as OrmAttributeParam; @@ -47,7 +49,11 @@ class AttributeFetcher /** @var array */ private $relatedEntitiesCacheMap = []; - public function __construct(private EntityManager $entityManager) {} + public function __construct( + private EntityManager $entityManager, + private FieldUtil $fieldUtil, + private SpecificFieldLoader $specificFieldLoader, + ) {} public function fetch(Entity $entity, string $attribute, bool $getFetchedAttribute = false): mixed { @@ -159,6 +165,14 @@ class AttributeFetcher return; } + + $field = $this->fieldUtil->getFieldOfAttribute($entity->getEntityType(), $attribute); + + if (!$field) { + return; + } + + $this->specificFieldLoader->process($entity, $field); } public function resetRuntimeCache(): void diff --git a/application/Espo/Core/Utils/FieldUtil.php b/application/Espo/Core/Utils/FieldUtil.php index 593c52e6ff..d9c027db24 100644 --- a/application/Espo/Core/Utils/FieldUtil.php +++ b/application/Espo/Core/Utils/FieldUtil.php @@ -289,4 +289,20 @@ class FieldUtil return $attributeList; } + + /** + * Get a field an attribute belongs to. + * + * @since 9.1.0 + */ + public function getFieldOfAttribute(string $entityType, string $attribute): ?string + { + foreach ($this->getEntityTypeFieldList($entityType) as $field) { + if (in_array($attribute, $this->getAttributeList($entityType, $field))) { + return $field; + } + } + + return null; + } } diff --git a/application/Espo/Resources/metadata/entityDefs/Email.json b/application/Espo/Resources/metadata/entityDefs/Email.json index 56188ffa58..e83f96efda 100644 --- a/application/Espo/Resources/metadata/entityDefs/Email.json +++ b/application/Espo/Resources/metadata/entityDefs/Email.json @@ -93,7 +93,8 @@ "detail", "filters" ], - "massUpdateDisabled": true + "massUpdateDisabled": true, + "loaderClassName": "Espo\\Classes\\FieldProcessing\\Email\\AddressDataLoader" }, "to": { "type": "varchar", @@ -112,7 +113,8 @@ "detail", "filters" ], - "massUpdateDisabled": true + "massUpdateDisabled": true, + "loaderClassName": "Espo\\Classes\\FieldProcessing\\Email\\AddressDataLoader" }, "cc": { "type": "varchar", @@ -129,7 +131,8 @@ "detail", "filters" ], - "massUpdateDisabled": true + "massUpdateDisabled": true, + "loaderClassName": "Espo\\Classes\\FieldProcessing\\Email\\AddressDataLoader" }, "bcc": { "type": "varchar", @@ -145,7 +148,8 @@ "layoutAvailabilityList": [ "detail" ], - "massUpdateDisabled": true + "massUpdateDisabled": true, + "loaderClassName": "Espo\\Classes\\FieldProcessing\\Email\\AddressDataLoader" }, "replyTo": { "type": "varchar", @@ -157,7 +161,8 @@ "layoutAvailabilityList": [ "detail" ], - "massUpdateDisabled": true + "massUpdateDisabled": true, + "loaderClassName": "Espo\\Classes\\FieldProcessing\\Email\\AddressDataLoader" }, "personStringData": { "type": "varchar", diff --git a/application/Espo/Resources/metadata/entityDefs/Portal.json b/application/Espo/Resources/metadata/entityDefs/Portal.json index 1ca88f3f84..200f446436 100644 --- a/application/Espo/Resources/metadata/entityDefs/Portal.json +++ b/application/Espo/Resources/metadata/entityDefs/Portal.json @@ -11,7 +11,8 @@ "url": { "type": "url", "notStorable": true, - "readOnly": true + "readOnly": true, + "loaderClassName": "Espo\\Classes\\FieldProcessing\\Portal\\UrlLoader" }, "customId": { "type": "varchar", diff --git a/application/Espo/Resources/metadata/recordDefs/Portal.json b/application/Espo/Resources/metadata/recordDefs/Portal.json index 87ec63ab16..7ff442d10b 100644 --- a/application/Espo/Resources/metadata/recordDefs/Portal.json +++ b/application/Espo/Resources/metadata/recordDefs/Portal.json @@ -1,10 +1,4 @@ { - "readLoaderClassNameList": [ - "Espo\\Classes\\FieldProcessing\\Portal\\UrlLoader" - ], - "listLoaderClassNameList": [ - "Espo\\Classes\\FieldProcessing\\Portal\\UrlLoader" - ], "massActions": { "delete": { "allowed": true diff --git a/schema/metadata/entityDefs.json b/schema/metadata/entityDefs.json index 63fcd394e5..20f219a0a9 100644 --- a/schema/metadata/entityDefs.json +++ b/schema/metadata/entityDefs.json @@ -1057,6 +1057,10 @@ "type": "string", "description": "Converts field defs to ORM metadata. Should implement Espo\\Core\\Utils\\Database\\Orm\\FieldConverter." }, + "loaderClassName": { + "type": "string", + "description": "Loader. Must implement Espo\\Core\\FieldProcessing\\Loader. As of v9.1." + }, "duplicateIgnore": { "type": "boolean", "description": "Ignore when duplicating a record." diff --git a/tests/unit/Espo/Core/Formula/EvaluatorTest.php b/tests/unit/Espo/Core/Formula/EvaluatorTest.php index 4457059c0f..5a50cedf0e 100644 --- a/tests/unit/Espo/Core/Formula/EvaluatorTest.php +++ b/tests/unit/Espo/Core/Formula/EvaluatorTest.php @@ -29,12 +29,15 @@ namespace tests\unit\Espo\Core\Formula; +use Espo\Core\Binding\BindingContainerBuilder; +use Espo\Core\FieldProcessing\SpecificFieldLoader; use Espo\Core\Formula\Evaluator; use Espo\Core\Formula\Exceptions\Error; use Espo\Core\Formula\Exceptions\UndefinedKey; use Espo\Core\Formula\Exceptions\UnsafeFunction; use Espo\Core\InjectableFactory; use Espo\Core\Formula\Exceptions\SyntaxError; +use Espo\Core\Utils\FieldUtil; use Espo\Core\Utils\Log; use Espo\ORM\EntityManager; @@ -58,9 +61,14 @@ class EvaluatorTest extends TestCase $container = $containerMocker->create([ 'log' => $log, 'entityManager' => $entityManager, + 'fieldUtil' => $this->createMock(FieldUtil::class), ]); - $injectableFactory = new InjectableFactory($container); + $bindingContainer = BindingContainerBuilder::create() + ->bindInstance(SpecificFieldLoader::class, $this->createMock(SpecificFieldLoader::class)) + ->build(); + + $injectableFactory = new InjectableFactory($container, $bindingContainer); $this->evaluator = new Evaluator($injectableFactory, [], ['test\\unsafe']); } diff --git a/tests/unit/Espo/Core/Formula/FormulaTest.php b/tests/unit/Espo/Core/Formula/FormulaTest.php index 143b26c911..2ef0ae5ac6 100644 --- a/tests/unit/Espo/Core/Formula/FormulaTest.php +++ b/tests/unit/Espo/Core/Formula/FormulaTest.php @@ -30,6 +30,7 @@ namespace tests\unit\Espo\Core\Formula; use Espo\Core\Binding\BindingContainerBuilder; +use Espo\Core\FieldProcessing\SpecificFieldLoader; use Espo\Core\Formula\AttributeFetcher; use Espo\Core\Formula\Parser\Ast\Attribute; use Espo\Core\Formula\Parser\Ast\Node; @@ -38,6 +39,7 @@ use Espo\Core\Formula\Parser\Ast\Variable; use Espo\Core\Formula\Processor; use Espo\Core\Formula\Argument; use Espo\Core\Utils\DateTime; +use Espo\Core\Utils\FieldUtil; use Espo\Core\Utils\NumberUtil; use Espo\Core\Utils\Config; use Espo\Core\Utils\Log; @@ -142,7 +144,10 @@ class FormulaTest extends TestCase ->build() ); - $attributeFetcher = new AttributeFetcher($this->entityManager); + $fieldUtil = $this->createMock(FieldUtil::class); + $loader = $this->createMock(SpecificFieldLoader::class); + + $attributeFetcher = new AttributeFetcher($this->entityManager, $fieldUtil, $loader); return new Processor( $injectableFactory,