. * * 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\Tools\FieldManager; use Espo\Core\ORM\Type\FieldType; use Espo\Core\Utils\Metadata; use Espo\Core\Utils\Language; use Espo\Core\InjectableFactory; use Espo\Core\Utils\Json; use Espo\Core\Exceptions\BadRequest; use Espo\Core\Exceptions\Conflict; use Espo\Core\Exceptions\Error; use Espo\Core\Utils\Metadata\Helper as MetadataHelper; use Espo\Core\Utils\Util; use Espo\Tools\EntityManager\NameUtil; use RuntimeException; use stdClass; /** * Field Manager tool. Administration > Entity Manager > fields. */ class FieldManager { private bool $isChanged = false; /** @var string[] */ private $forbiddenFieldNameList = [ 'id', 'deleted', 'skipDuplicateCheck', 'isFollowed', 'isStarred', 'versionNumber', 'null', 'false', 'true', ]; /** @var string[] */ private $forbiddenAnyCaseFieldNameList = [ 'id', 'deleted', 'null', 'false', 'true', 'system', ]; // 64 - margin (for attribute name suffixes and prefixes) private const MAX_NAME_LENGTH = 50; /** @var array> */ private array $defaultParams = [ FieldType::ENUM => [ 'maxLength' => 100, ], ]; public function __construct( private InjectableFactory $injectableFactory, private Metadata $metadata, private Language $language, private Language $baseLanguage, private MetadataHelper $metadataHelper, private NameUtil $nameUtil ) {} /** * @return array * @throws Error */ public function read(string $scope, string $name): array { $fieldDefs = $this->getFieldDefs($scope, $name); if ($fieldDefs === null) { throw new Error("Can't read field defs $scope.$name."); } $fieldDefs['label'] = $this->language->translate($name, 'fields', $scope); $type = $this->metadata->get(['entityDefs', $scope, 'fields', $name, 'type']); $this->processHook('onRead', $type, $scope, $name, $fieldDefs); return $fieldDefs; } /** * @param array $fieldDefs * @return string An actual name. * @throws BadRequest * @throws Conflict * @throws Error */ public function create(string $scope, string $name, array $fieldDefs): string { if (strlen($name) === 0) { throw new BadRequest("Empty field name."); } if (!$this->isScopeCustom($scope)) { $name = $this->nameUtil->addCustomPrefix($name); } if (!$this->isScopeCustomizable($scope)) { throw new Error("Entity type $scope is not customizable."); } if (strlen(Util::camelCaseToUnderscore($name)) > self::MAX_NAME_LENGTH) { throw Error::createWithBody( "Field name should not be longer than " . self::MAX_NAME_LENGTH . ".", Error\Body::create() ->withMessageTranslation('nameIsTooLong', 'EntityManager') ->encode() ); } $existingField = $this->getFieldDefs($scope, $name); if (isset($existingField)) { throw Conflict::createWithBody( "Field '$name' already exists in '$scope'.", Error\Body::create() ->withMessageTranslation('fieldAlreadyExists', 'FieldManager', [ 'field' => $name, 'entityType' => $scope, ]) ->encode() ); } if ($this->metadata->get(['entityDefs', $scope, 'links', $name])) { throw Conflict::createWithBody( "Link with name '$name' already exists in '$scope'.", Error\Body::create() ->withMessageTranslation('linkWithSameNameAlreadyExists', 'FieldManager', [ 'field' => $name, 'entityType' => $scope, ]) ->encode() ); } if ( in_array($name, $this->forbiddenFieldNameList) || in_array(strtolower($name), $this->forbiddenAnyCaseFieldNameList) ) { throw Conflict::createWithBody( "Field '$name' is not allowed.", Error\Body::create() ->withMessageTranslation('fieldNameIsNotAllowed', 'FieldManager', [ 'field' => $name, ]) ->encode() ); } $firstLatter = $name[0]; if (is_numeric($firstLatter)) { throw new Error('Field name should start with a letter.'); } if (preg_match('/[^a-z]/', $firstLatter)) { throw new Error("Field name should start with a lower case letter."); } if (preg_match('/[^a-zA-Z\d]/', $name)) { throw new Error("Field name should contain only letters and numbers."); } $type = $fieldDefs['type'] ?? null; if (!$type) { throw new BadRequest("No type."); } foreach (($this->defaultParams[$type] ?? []) as $param => $value) { if (!array_key_exists($param, $fieldDefs)) { $fieldDefs[$param] = $value; } } $this->update($scope, $name, $fieldDefs, true); return $name; } /** * @param array $fieldDefs * @throws Error */ public function update(string $scope, string $name, array $fieldDefs, bool $isNew = false): void { $name = trim($name); $this->isChanged = false; if (!$this->isCore($scope, $name)) { $fieldDefs['isCustom'] = true; } if (!$this->isScopeCustomizable($scope)) { throw new Error("Entity type $scope is not customizable."); } $isCustom = false; if (!empty($fieldDefs['isCustom'])) { $isCustom = true; } $isLabelChanged = false; if (isset($fieldDefs['label'])) { $this->setLabel($scope, $name, $fieldDefs['label'], $isNew, $isCustom); $isLabelChanged = true; } if (isset($fieldDefs['tooltipText'])) { $this->setTooltipText($scope, $name, $fieldDefs['tooltipText']); $isLabelChanged = true; } $type = $fieldDefs['type'] ?? $this->metadata->get(['entityDefs', $scope, 'fields', $name, 'type']); $this->processHook('beforeSave', $type, $scope, $name, $fieldDefs, ['isNew' => $isNew]); if ($this->metadata->get(['fields', $type, 'translatedOptions'])) { if (isset($fieldDefs['translatedOptions'])) { $translatedOptions = json_decode(Json::encode($fieldDefs['translatedOptions']), true); if (isset($translatedOptions['_empty_'])) { $translatedOptions[''] = $translatedOptions['_empty_']; unset($translatedOptions['_empty_']); } $this->setTranslatedOptions($scope, $name, $translatedOptions, $isNew, $isCustom); $isLabelChanged = true; } } if ($isNew) { $subFieldsDefs = $this->metadata->get(['fields', $type, 'fields']); if ($subFieldsDefs) { foreach ($subFieldsDefs as $partField => $partFieldData) { $partLabel = $this->language->get('FieldManager.fieldParts.' . $type . '.' . $partField); if ($partLabel) { if ($this->metadata->get(['fields', $type, 'fields', 'naming']) === 'prefix') { $subFieldName = $partField . ucfirst($name); $subFieldLabel = $partLabel . ' ' . $fieldDefs['label']; } else { $subFieldName = $name . ucfirst($partField); $subFieldLabel = $fieldDefs['label'] . ' ' . $partLabel; } $this->setLabel($scope, $subFieldName, $subFieldLabel, $isNew, $isCustom); $isLabelChanged = true; } } } } if ($isLabelChanged) { $this->language->save(); if ($isNew || $isCustom) { if ($this->baseLanguage->getLanguage() !== $this->language->getLanguage()) { $this->baseLanguage->save(); } } } $metadataToBeSaved = false; $clientDefsToBeSet = false; $clientDefs = []; if (array_key_exists('dynamicLogicVisible', $fieldDefs)) { if (!is_null($fieldDefs['dynamicLogicVisible'])) { $this->prepareClientDefsFieldsDynamicLogic($clientDefs, $name); $clientDefs['dynamicLogic']['fields'][$name]['visible'] = $fieldDefs['dynamicLogicVisible']; $clientDefsToBeSet = true; } else { if ( $this->metadata->get(['clientDefs', $scope, 'dynamicLogic', 'fields', $name, 'visible']) ) { $this->prepareClientDefsFieldsDynamicLogic($clientDefs, $name); $clientDefs['dynamicLogic']['fields'][$name]['visible'] = null; $clientDefsToBeSet = true; } } } if (array_key_exists('dynamicLogicReadOnly', $fieldDefs)) { if (!is_null($fieldDefs['dynamicLogicReadOnly'])) { $this->prepareClientDefsFieldsDynamicLogic($clientDefs, $name); $clientDefs['dynamicLogic']['fields'][$name]['readOnly'] = $fieldDefs['dynamicLogicReadOnly']; $clientDefsToBeSet = true; } else { if ( $this->metadata->get(['clientDefs', $scope, 'dynamicLogic', 'fields', $name, 'readOnly']) ) { $this->prepareClientDefsFieldsDynamicLogic($clientDefs, $name); $clientDefs['dynamicLogic']['fields'][$name]['readOnly'] = null; $clientDefsToBeSet = true; } } } if (array_key_exists('dynamicLogicRequired', $fieldDefs)) { if (!is_null($fieldDefs['dynamicLogicRequired'])) { $this->prepareClientDefsFieldsDynamicLogic($clientDefs, $name); $clientDefs['dynamicLogic']['fields'][$name]['required'] = $fieldDefs['dynamicLogicRequired']; $clientDefsToBeSet = true; } else { if ( $this->metadata->get(['clientDefs', $scope, 'dynamicLogic', 'fields', $name, 'required']) ) { $this->prepareClientDefsFieldsDynamicLogic($clientDefs, $name); $clientDefs['dynamicLogic']['fields'][$name]['required'] = null; $clientDefsToBeSet = true; } } } if (array_key_exists('dynamicLogicOptions', $fieldDefs)) { if (!is_null($fieldDefs['dynamicLogicOptions'])) { $this->prepareClientDefsOptionsDynamicLogic($clientDefs, $name); $clientDefs['dynamicLogic']['options'][$name] = $fieldDefs['dynamicLogicOptions']; $clientDefsToBeSet = true; } else { if ($this->metadata->get(['clientDefs', $scope, 'dynamicLogic', 'options', $name])) { $this->prepareClientDefsOptionsDynamicLogic($clientDefs, $name); $clientDefs['dynamicLogic']['options'][$name] = null; $clientDefsToBeSet = true; } } } if (array_key_exists('dynamicLogicInvalid', $fieldDefs)) { if (!is_null($fieldDefs['dynamicLogicInvalid'])) { $this->prepareClientDefsFieldsDynamicLogic($clientDefs, $name); $clientDefs['dynamicLogic']['fields'][$name]['invalid'] = $fieldDefs['dynamicLogicInvalid']; $clientDefsToBeSet = true; } else { if ( $this->metadata->get(['clientDefs', $scope, 'dynamicLogic', 'fields', $name, 'invalid']) ) { $this->prepareClientDefsFieldsDynamicLogic($clientDefs, $name); $clientDefs['dynamicLogic']['fields'][$name]['invalid'] = null; $clientDefsToBeSet = true; } } } if ($clientDefsToBeSet) { $this->metadata->set('clientDefs', $scope, $clientDefs); $metadataToBeSaved = true; } $entityDefs = $this->normalizeDefs($scope, $name, $fieldDefs); if (!empty((array) $entityDefs)) { $this->saveCustomEntityDefs($scope, $entityDefs); $this->isChanged = true; } if ($metadataToBeSaved) { $this->metadata->save(); $this->isChanged = true; } if ($this->isChanged) { $this->processHook('afterSave', $type, $scope, $name, $fieldDefs, ['isNew' => $isNew]); } } /** * @param array $clientDefs * @param string $name */ private function prepareClientDefsFieldsDynamicLogic(&$clientDefs, $name): void { if (!array_key_exists('dynamicLogic', $clientDefs)) { $clientDefs['dynamicLogic'] = []; } if (!array_key_exists('fields', $clientDefs['dynamicLogic'])) { $clientDefs['dynamicLogic']['fields'] = []; } if (!array_key_exists($name, $clientDefs['dynamicLogic']['fields'])) { $clientDefs['dynamicLogic']['fields'][$name] = []; } } /** * @param array $clientDefs * @param string $name */ private function prepareClientDefsOptionsDynamicLogic(&$clientDefs, $name): void { if (!array_key_exists('dynamicLogic', $clientDefs)) { $clientDefs['dynamicLogic'] = []; } if (!array_key_exists('options', $clientDefs['dynamicLogic'])) { $clientDefs['dynamicLogic']['options'] = []; } if (!array_key_exists($name, $clientDefs['dynamicLogic']['options'])) { $clientDefs['dynamicLogic']['options'][$name] = []; } } /** * @throws Error */ public function delete(string $scope, string $name): void { if ($this->isCore($scope, $name)) { throw new Error("Cannot delete core field '$name' in '$scope'."); } if (!$this->isScopeCustomizable($scope)) { throw new Error("Entity type $scope is not customizable."); } $type = $this->metadata->get(['entityDefs', $scope, 'fields', $name, 'type']); $this->processHook('beforeRemove', $type, $scope, $name); $unsets = [ 'fields.' . $name, 'links.' . $name, ]; if ( !$this->isScopeCustom($scope) && $this->metadata->get("entityDefs.$scope.collection.orderBy") === $name ) { $unsets[] = "entityDefs.$scope.collection.orderBy"; $unsets[] = "entityDefs.$scope.collection.order"; } $this->metadata->delete('entityDefs', $scope, $unsets); $this->metadata->delete('clientDefs', $scope, [ 'dynamicLogic.fields.' . $name, 'dynamicLogic.options.' . $name, ]); $this->metadata->save(); $this->deleteLabel($scope, $name); $subFieldsDefs = $this->metadata->get(['fields', $type, 'fields']); if ($subFieldsDefs) { foreach ($subFieldsDefs as $partField => $partFieldData) { if ($this->metadata->get(['fields', $type, 'fields', 'naming']) === 'prefix') { $subFieldName = $partField . ucfirst($name); } else { $subFieldName = $name . ucfirst($partField); } $this->deleteLabel($scope, $subFieldName); } } $this->language->save(); if ($this->baseLanguage->getLanguage() !== $this->language->getLanguage()) { $this->baseLanguage->save(); } $this->processHook('afterRemove', $type, $scope, $name); } /** * @throws Error */ public function resetToDefault(string $scope, string $name): void { if (!$this->isCore($scope, $name)) { throw new Error("Cannot reset to default custom field '$name' in '$scope'."); } if (!$this->metadata->get(['entityDefs', $scope, 'fields', $name])) { throw new Error("Not found field field '$name' in '$scope'."); } $this->metadata->delete('entityDefs', $scope, ['fields.' . $name]); $this->metadata->delete('clientDefs', $scope, [ 'dynamicLogic.fields.' . $name, 'dynamicLogic.options.' . $name, ]); $this->metadata->save(); $this->language->delete($scope, 'fields', $name); $this->language->delete($scope, 'options', $name); $this->language->delete($scope, 'tooltips', $name); $this->language->save(); } /** * @param array $value */ private function setTranslatedOptions( string $scope, string $name, $value, bool $isNew, bool $isCustom ): void { if ($isNew || $isCustom) { $this->baseLanguage->set($scope, 'options', $name, $value); } $this->language->set($scope, 'options', $name, $value); } private function setLabel( string $scope, string $name, string $value, bool $isNew, bool $isCustom ): void { if ($isNew || $isCustom) { $this->baseLanguage->set($scope, 'fields', $name, $value); } $this->language->set($scope, 'fields', $name, $value); } private function setTooltipText(string $scope, string $name, string $value): void { if ($value && $value !== '') { $this->language->set($scope, 'tooltips', $name, $value); $this->baseLanguage->set($scope, 'tooltips', $name, $value); } else { $this->language->delete($scope, 'tooltips', $name); $this->baseLanguage->delete($scope, 'tooltips', $name); } } private function deleteLabel(string $scope, string $name): void { $this->language->delete($scope, 'fields', $name); $this->language->delete($scope, 'tooltips', $name); $this->language->delete($scope, 'options', $name); $this->baseLanguage->delete($scope, 'fields', $name); $this->baseLanguage->delete($scope, 'tooltips', $name); $this->baseLanguage->delete($scope, 'options', $name); } /** * @param ?stdClass $default * @return ?array */ private function getFieldDefs(string $scope, string $name, $default = null) { $defs = $this->metadata->getObjects(['entityDefs', $scope, 'fields', $name], $default); if (is_object($defs)) { return get_object_vars($defs); } return $defs; } /** * @param ?array $default * @return ?array */ private function getCustomFieldDefs(string $scope, string $name, $default = null) { $customDefs = $this->metadata->getCustom('entityDefs', $scope, (object) []); if (isset($customDefs->fields->$name)) { return (array) $customDefs->fields->$name; } return $default; } /** * @param stdClass $newDefs */ private function saveCustomEntityDefs(string $scope, $newDefs): void { $customDefs = $this->metadata->getCustom('entityDefs', $scope, (object) []); if (isset($newDefs->fields)) { foreach ($newDefs->fields as $name => $defs) { if (!isset($customDefs->fields)) { $customDefs->fields = new stdClass(); } $customDefs->fields->$name = $defs; } } if (isset($newDefs->links)) { foreach ($newDefs->links as $name => $defs) { if (!isset($customDefs->links)) { $customDefs->links = new stdClass(); } $customDefs->links->$name = $defs; } } $this->metadata->saveCustom('entityDefs', $scope, $customDefs); } /** * @param array $fieldDefs * @return array */ private function prepareFieldDefs(string $scope, string $name, array $fieldDefs): array { $additionalParamList = [ 'type' => [ 'type' => 'varchar', ], 'isCustom' => [ 'type' => 'bool', 'default' => false, ], 'isPersonalData' => [ 'type' => 'bool', 'default' => false, ], 'tooltip' => [ 'type' => 'bool', 'default' => false, ], 'inlineEditDisabled' => [ 'type' => 'bool', 'default' => false, ], 'defaultAttributes' => [ 'type' => 'jsonObject', ], ]; $type = $fieldDefs['type'] ?? null; if (!$type) { throw new RuntimeException("No type."); } foreach (($fieldDefs['fieldManagerAdditionalParamList'] ?? []) as $additionalParam) { $additionalParamList[$additionalParam->name] = [ 'type' => $type, ]; } $fieldDefsByType = $this->metadataHelper->getFieldDefsByType($fieldDefs); $paramDataList = $fieldDefsByType['params'] ?? []; $params = []; foreach ($paramDataList as $paramData) { $params[$paramData['name']] = $paramData; } foreach ($additionalParamList as $paramName => $paramValue) { if (!isset($params[$paramName])) { $params[$paramName] = array_merge(['name' => $paramName], $paramValue); } } $actualCustomFieldDefs = $this->getCustomFieldDefs($scope, $name, []); $actualFieldDefs = $this->getFieldDefs($scope, $name, (object) []); assert($actualFieldDefs !== null); assert($actualCustomFieldDefs !== null); $permittedParamList = array_unique(array_merge( array_keys($params), array_keys($this->defaultParams[$type] ?? []) )); $filteredFieldDefs = !empty($actualCustomFieldDefs) ? $actualCustomFieldDefs : []; foreach ($fieldDefs as $paramName => $paramValue) { if (!in_array($paramName, $permittedParamList)) { continue; } $defaultParamValue = null; $paramType = $params[$paramName]['type'] ?? null; if ($paramType === FieldType::BOOL) { $defaultParamValue = false; } $actualValue = array_key_exists($paramName, $actualFieldDefs) ? $actualFieldDefs[$paramName] : $defaultParamValue; if ( !array_key_exists($paramName, $actualCustomFieldDefs) && !Util::areValuesEqual($actualValue, $paramValue) ) { $filteredFieldDefs[$paramName] = $paramValue; continue; } if (array_key_exists($paramName, $actualCustomFieldDefs)) { $filteredFieldDefs[$paramName] = $paramValue; } } $metaFieldDefs = $this->metadataHelper->getFieldDefsInFieldMetadata($filteredFieldDefs); if (isset($metaFieldDefs)) { $filteredFieldDefs = Util::merge($metaFieldDefs, $filteredFieldDefs); } if ($actualCustomFieldDefs) { $actualCustomFieldDefs = array_diff_key($actualCustomFieldDefs, array_flip($permittedParamList)); foreach ($actualCustomFieldDefs as $paramName => $paramValue) { if (!array_key_exists($paramName, $filteredFieldDefs)) { $filteredFieldDefs[$paramName] = $paramValue; } } } /** @var array */ return $filteredFieldDefs; } /** * @param array $fieldDefs */ private function normalizeDefs(string $scope, string $fieldName, array $fieldDefs): stdClass { $defs = new stdClass(); $normalizedFieldDefs = $this->prepareFieldDefs($scope, $fieldName, $fieldDefs); if (!empty($normalizedFieldDefs)) { $defs->fields = (object) [ $fieldName => (object) $normalizedFieldDefs, ]; } /** Save links for a field. */ $linkDefs = $fieldDefs['linkDefs'] ?? null; $metaLinkDefs = $this->metadataHelper->getLinkDefsInFieldMeta($scope, $fieldDefs); if (isset($linkDefs) || isset($metaLinkDefs)) { $metaLinkDefs = $metaLinkDefs ?? []; $linkDefs = $linkDefs ?? []; $normalizedLinkedDefs = Util::merge($metaLinkDefs, $linkDefs); if (!empty($normalizedLinkedDefs)) { $defs->links = (object) [ $fieldName => (object) $normalizedLinkedDefs, ]; } } return $defs; } public function isChanged(): bool { return $this->isChanged; } private function isCore(string $scope, string $name): bool { $existingField = $this->getFieldDefs($scope, $name); if (isset($existingField) && (!isset($existingField['isCustom']) || !$existingField['isCustom'])) { return true; } return false; } private function isScopeCustom(string $scope): bool { return (bool) $this->metadata->get("scopes.$scope.isCustom"); } /** * @todo Add interfaces for hooks. * * @param string $name * @param array $defs * @param array $options */ private function processHook( string $methodName, ?string $type, string $scope, string $name, &$defs = null, $options = [] ): void { if (!$type) { return; } $hook = $this->getHook($type); if (!$hook) { return; } if (!method_exists($hook, $methodName)) { return; } $hook->$methodName($scope, $name, $defs, $options); } private function getHook(string $type): ?object { /** @var ?class-string $className */ $className = $this->metadata->get(['fields', $type, 'hookClassName']); if (!$className) { return null; } return $this->injectableFactory->create($className); } private function isScopeCustomizable(string $scope): bool { if (!$this->metadata->get("scopes.$scope.customizable")) { return false; } if ($this->metadata->get("scopes.$scope.entityManager.fields") === false) { return false; } return true; } }