diff --git a/application/Espo/Core/ApplicationRunners/Command.php b/application/Espo/Core/ApplicationRunners/Command.php index 9281c1e0e2..b79045e7af 100644 --- a/application/Espo/Core/ApplicationRunners/Command.php +++ b/application/Espo/Core/ApplicationRunners/Command.php @@ -42,7 +42,7 @@ class Command implements Runner use Cli; use SetupSystemUser; - private $commandManager; + private ConsoleCommandManager $commandManager; public function __construct(ConsoleCommandManager $commandManager) { @@ -52,10 +52,14 @@ class Command implements Runner public function run(): void { try { - $this->commandManager->run($_SERVER['argv']); + $exitStatus = $this->commandManager->run($_SERVER['argv']); } catch (Exception $e) { echo "Error: " . $e->getMessage() . "\n"; + + exit(1); } + + exit($exitStatus); } } diff --git a/application/Espo/Core/Console/CommandManager.php b/application/Espo/Core/Console/CommandManager.php index 252334b8fb..a3b90ee04e 100644 --- a/application/Espo/Core/Console/CommandManager.php +++ b/application/Espo/Core/Console/CommandManager.php @@ -57,8 +57,10 @@ class CommandManager /** * @param array $argv + * + * @return int<0, 255> Exit-status. */ - public function run(array $argv): void + public function run(array $argv): int { $command = $this->getCommandNameFromArgv($argv); $params = $this->createParamsFromArgv($argv); @@ -89,10 +91,12 @@ class CommandManager $commandObj->run($params->getOptions(), $params->getFlagList(), $params->getArgumentList()); - return; + return 0; } $commandObj->run($params, $io); + + return $io->getExitStatus(); } /** diff --git a/application/Espo/Core/Console/Commands/EntityUtil.php b/application/Espo/Core/Console/Commands/EntityUtil.php new file mode 100644 index 0000000000..2115b91dde --- /dev/null +++ b/application/Espo/Core/Console/Commands/EntityUtil.php @@ -0,0 +1,157 @@ +renamer = $renamer; + } + + public function run(Params $params, IO $io): void + { + $subCommand = $params->getArgument(0); + + if (!$subCommand) { + $io->writeLine("No sub-command specified."); + + return; + } + + if ($subCommand === 'rename') { + $this->runRename($params, $io); + + return; + } + } + + private function runRename(Params $params, IO $io): void + { + $entityType = $params->getOption('entityType'); + $newName = $params->getOption('newName'); + + if (!$entityType) { + $io->writeLine("No --entity-type option specified."); + } + + if (!$newName) { + $io->writeLine("No --new-name option specified."); + } + + if (!$entityType || !$newName) { + return; + } + + $result = $this->renamer->process($entityType, $newName, $io); + + $io->writeLine(""); + + if (!$result->isFail()) { + $io->writeLine("Finished."); + + return; + } + + $io->setExitStatus(1); + $io->write("Failed. "); + + $failReason = $result->getFailReason(); + + if ($failReason === RenameFailReason::NAME_BAD) { + $io->writeLine("Name is bad."); + + return; + } + + if ($failReason === RenameFailReason::NAME_NOT_ALLOWED) { + $io->writeLine("Name is not allowed."); + + return; + } + + if ($failReason === RenameFailReason::NAME_TOO_LONG) { + $io->writeLine("Name is too long."); + + return; + } + + if ($failReason === RenameFailReason::NAME_TOO_SHORT) { + $io->writeLine("Name is too short."); + + return; + } + + if ($failReason === RenameFailReason::NAME_USED) { + $io->writeLine("Name is already used."); + + return; + } + + if ($failReason === RenameFailReason::DOES_NOT_EXIST) { + $io->writeLine("Entity type `{$entityType}` does not exist."); + + return; + } + + if ($failReason === RenameFailReason::NOT_CUSTOM) { + $io->writeLine("Entity type `{$entityType}` is not custom, hence can't be renamed."); + + return; + } + + if ($failReason === RenameFailReason::ENV_NOT_SUPPORTED) { + $io->writeLine("Environment is not supported."); + + return; + } + + if ($failReason === RenameFailReason::TABLE_EXISTS) { + $io->writeLine("Table already exists."); + + return; + } + + if ($failReason === RenameFailReason::ERROR) { + $io->writeLine("Error occurred."); + + return; + } + } +} diff --git a/application/Espo/Core/Console/Commands/Extension.php b/application/Espo/Core/Console/Commands/Extension.php index c2e9990776..3286ee1d7a 100644 --- a/application/Espo/Core/Console/Commands/Extension.php +++ b/application/Espo/Core/Console/Commands/Extension.php @@ -70,6 +70,7 @@ class Extension implements Command if (!$name && !$id) { $io->writeLine("Can't uninstall. Specify --name=\"Extension Name\"."); + $io->setExitStatus(1); return; } @@ -112,6 +113,7 @@ class Extension implements Command if (!$this->fileManager->isFile($file)) { $io->writeLine("File does not exist."); + $io->setExitStatus(1); return; } @@ -125,6 +127,7 @@ class Extension implements Command } catch (Throwable $e) { $io->writeLine($e->getMessage()); + $io->setExitStatus(1); return; } @@ -136,6 +139,7 @@ class Extension implements Command if (!$name) { $io->writeLine("Can't install. Bad manifest.json file."); + $io->setExitStatus(1); return; } @@ -148,6 +152,7 @@ class Extension implements Command catch (Throwable $e) { $io->writeLine(""); $io->writeLine($e->getMessage()); + $io->setExitStatus(1); return; } @@ -173,6 +178,7 @@ class Extension implements Command if (!$record) { $io->writeLine("Extension with ID '{$id}' is not installed."); + $io->setExitStatus(1); return; } @@ -182,6 +188,7 @@ class Extension implements Command else { if (!$name) { $io->writeLine("Can't uninstall. No --name or --id specified."); + $io->setExitStatus(1); return; } @@ -196,6 +203,7 @@ class Extension implements Command if (!$record) { $io->writeLine("Extension '{$name}' is not installed."); + $io->setExitStatus(1); return; } @@ -213,6 +221,7 @@ class Extension implements Command catch (Throwable $e) { $io->writeLine(""); $io->writeLine($e->getMessage()); + $io->setExitStatus(1); return; } @@ -221,11 +230,11 @@ class Extension implements Command if ($toKeep) { $io->writeLine("Extension '{$name}' is uninstalled."); + $io->setExitStatus(1); return; } - try { $manager->delete(['id' => $id]); } diff --git a/application/Espo/Core/Console/Commands/RunJob.php b/application/Espo/Core/Console/Commands/RunJob.php index 091c6649de..71e322f097 100644 --- a/application/Espo/Core/Console/Commands/RunJob.php +++ b/application/Espo/Core/Console/Commands/RunJob.php @@ -110,6 +110,7 @@ class RunJob implements Command } $io->writeLine($message); + $io->setExitStatus(1); return; } diff --git a/application/Espo/Core/Console/Commands/SetPassword.php b/application/Espo/Core/Console/Commands/SetPassword.php index 9bdd0a4c9e..15afdedab2 100644 --- a/application/Espo/Core/Console/Commands/SetPassword.php +++ b/application/Espo/Core/Console/Commands/SetPassword.php @@ -55,6 +55,7 @@ class SetPassword implements Command if (!$userName) { $io->writeLine("User name must be specified."); + $io->setExitStatus(1); return; } @@ -67,6 +68,7 @@ class SetPassword implements Command if (!$user) { $io->writeLine("User '{$userName}' not found."); + $io->setExitStatus(1); return; } @@ -74,9 +76,8 @@ class SetPassword implements Command if (!in_array($user->get('type'), ['admin', 'super-admin', 'portal', 'regular'])) { $userType = $user->get('type'); - $io->writeLine( - "Can't set password for a user of the type '{$userType}'." - ); + $io->writeLine("Can't set password for a user of the type '{$userType}'."); + $io->setExitStatus(1); return; } @@ -87,13 +88,12 @@ class SetPassword implements Command if (!$password) { $io->writeLine("Password can not be empty."); + $io->setExitStatus(1); return; } - $hash = $this->passwordHash; - - $user->set('password', $hash->hash($password)); + $user->set('password', $this->passwordHash->hash($password)); $em->saveEntity($user); diff --git a/application/Espo/Core/Console/IO.php b/application/Espo/Core/Console/IO.php index b2736614ab..20b31acfc1 100644 --- a/application/Espo/Core/Console/IO.php +++ b/application/Espo/Core/Console/IO.php @@ -39,6 +39,11 @@ use const PHP_EOL; */ class IO { + /** + * @var int<0, 255> + */ + private int $exitStatus = 0; + /** * Write a string to output. */ @@ -78,4 +83,27 @@ class IO return $string; } + + /** + * Set exit-status. + * + * @param int<0, 255> $exitStatus + * - `0` - success; + * - `1` - error; + * - `127` - command not found; + */ + public function setExitStatus(int $exitStatus): void + { + $this->exitStatus = $exitStatus; + } + + /** + * Get exit-status. + * + * @return int<0, 255> + */ + public function getExitStatus(): int + { + return $this->exitStatus; + } } diff --git a/application/Espo/ORM/QueryComposer/BaseQueryComposer.php b/application/Espo/ORM/QueryComposer/BaseQueryComposer.php index 1c4d887ba6..339131b071 100644 --- a/application/Espo/ORM/QueryComposer/BaseQueryComposer.php +++ b/application/Espo/ORM/QueryComposer/BaseQueryComposer.php @@ -2373,7 +2373,11 @@ abstract class BaseQueryComposer implements QueryComposer $string[0] = strtolower($string[0]); /** @var string */ - $dbString = preg_replace_callback('/([A-Z])/', [$this, 'toDbMatchConversion'], $string); + $dbString = preg_replace_callback( + '/([A-Z])/', + fn($matches) => '_' . strtolower($matches[1]), + $string + ); $this->attributeDbMapCache[$string] = $dbString; } @@ -2381,13 +2385,6 @@ abstract class BaseQueryComposer implements QueryComposer return $this->attributeDbMapCache[$string]; } - /** - * @param array $matches - */ - protected function toDbMatchConversion(array $matches): string - { - return '_' . strtolower($matches[1]); - } protected function getAlias(Entity $entity, string $relationName): ?string { diff --git a/application/Espo/Tools/EntityManager/EntityManager.php b/application/Espo/Tools/EntityManager/EntityManager.php index c15358ae0b..a7903fb329 100644 --- a/application/Espo/Tools/EntityManager/EntityManager.php +++ b/application/Espo/Tools/EntityManager/EntityManager.php @@ -48,8 +48,6 @@ use Espo\Core\{ Utils\Language, Utils\Config, Utils\Config\ConfigWriter, - ORM\EntityManager as OrmEntityManager, - ServiceFactory, DataManager, InjectableFactory, }; @@ -73,63 +71,13 @@ class EntityManager private $configWriter; - private $entityManager; - - private $serviceFactory; - private $injectableFactory; private $dataManager; private LinkHookProcessor $linkHookProcessor; - private const MAX_ENTITY_NAME = 100; - - private const MAX_LINK_NAME = 100; - - /** - * @var string[] - */ - private $reservedWordList = [ - '__halt_compiler', 'abstract', 'and', 'array', 'as', 'break', 'callable', - 'case', 'catch', 'class', 'clone', 'const', 'continue', 'declare', 'default', - 'die', 'do', 'echo', 'else', 'elseif', 'empty', 'enddeclare', 'endfor', 'endforeach', - 'endif', 'endswitch', 'endwhile', 'eval', 'exit', 'extends', 'final', 'for', 'foreach', - 'function', 'global', 'goto', 'if', 'implements', 'include', 'include_once', 'instanceof', - 'insteadof', 'interface', 'isset', 'list', 'namespace', 'new', 'or', 'print', 'private', - 'protected', 'public', 'require', 'require_once', 'return', 'static', 'switch', 'throw', - 'trait', 'try', 'unset', 'use', 'var', 'while', 'xor', 'common', 'fn', 'parent', - ]; - - /** - * @var string[] - */ - private $linkForbiddenNameList = [ - 'posts', - 'stream', - 'subscription', - 'followers', - 'action', - 'null', - 'false', - 'true', - ]; - - /** - * @var string[] - */ - private $forbiddenEntityTypeNameList = [ - 'Common', - 'PortalUser', - 'ApiUser', - 'Timeline', - 'About', - 'Admin', - 'Null', - 'False', - 'True', - 'Base', - ]; + private NameUtil $nameUtil; public function __construct( Metadata $metadata, @@ -138,11 +86,10 @@ class EntityManager FileManager $fileManager, Config $config, ConfigWriter $configWriter, - OrmEntityManager $entityManager, - ServiceFactory $serviceFactory, DataManager $dataManager, InjectableFactory $injectableFactory, - LinkHookProcessor $linkHookProcessor + LinkHookProcessor $linkHookProcessor, + NameUtil $nameUtil ) { $this->metadata = $metadata; $this->language = $language; @@ -150,74 +97,10 @@ class EntityManager $this->fileManager = $fileManager; $this->config = $config; $this->configWriter = $configWriter; - $this->entityManager = $entityManager; - $this->serviceFactory = $serviceFactory; $this->dataManager = $dataManager; $this->injectableFactory = $injectableFactory; $this->linkHookProcessor = $linkHookProcessor; - } - - protected function checkControllerExists(string $name): bool - { - $controllerClassName = 'Espo\\Custom\\Controllers\\' . Util::normilizeClassName($name); - - if (class_exists($controllerClassName)) { - return true; - } - else { - foreach ($this->metadata->getModuleList() as $moduleName) { - $controllerClassName = - 'Espo\\Modules\\' . $moduleName . '\\Controllers\\' . Util::normilizeClassName($name); - - if (class_exists($controllerClassName)) { - return true; - } - } - - $controllerClassName = 'Espo\\Controllers\\' . Util::normilizeClassName($name); - - if (class_exists($controllerClassName)) { - return true; - } - } - - return false; - } - - protected function checkRelationshipExists(string $name): bool - { - $name = ucfirst($name); - - /** @var string[] */ - $scopeList = array_keys($this->metadata->get(['scopes'], [])); - - foreach ($scopeList as $entityType) { - $relationsDefs = $this->entityManager - ->getMetadata() - ->get($entityType, 'relations'); - - if (empty($relationsDefs)) { - continue; - } - - foreach ($relationsDefs as $link => $item) { - if (empty($item['type'])) { - continue; - } - - if (empty($item['relationName'])) { - continue; - } - - if ($item['type'] === 'manyMany') { - if (ucfirst($item['relationName']) === $name) { - return true; - } - } - } - } - - return false; + $this->nameUtil = $nameUtil; } /** @@ -236,14 +119,6 @@ class EntityManager throw new BadRequest(); } - if (strlen($name) > self::MAX_ENTITY_NAME) { - throw new Error("Entity name should not be longer than " . self::MAX_ENTITY_NAME . "."); - } - - if (is_numeric($name[0])) { - throw new Error('Bad entity name.'); - } - if (!in_array($type, $this->metadata->get(['app', 'entityTemplateList'], []))) { throw new Error('Type \''.$type.'\' does not exist.'); } @@ -254,48 +129,25 @@ class EntityManager throw new Error('Type \''.$type.'\' is not creatable.'); } - if ($this->metadata->get('scopes.' . $name)) { - throw new Conflict('Entity \''.$name.'\' already exists.'); + if ($this->nameUtil->nameIsBad($name)) { + throw new Error("Entity name should contain only letters and numbers, " . + "start with an upper case letter."); } - if ($this->metadata->get(['entityDefs.', $name])) { - throw new Conflict('Entity \''.$name.'\' already exists.'); + if ($this->nameUtil->nameIsTooShort($name)) { + throw new Error("Entity name should not shorter than " . NameUtil::MIN_ENTITY_NAME_LENGTH . "."); } - if ($this->metadata->get(['clientDefs.', $name])) { - throw new Conflict('Entity \''.$name.'\' already exists.'); + if ($this->nameUtil->nameIsTooLong($name)) { + throw new Error("Entity name should not be longer than " . NameUtil::MAX_ENTITY_NAME_LENGTH . "."); } - if ($this->checkControllerExists($name)) { - throw new Conflict('Entity name \''.$name.'\' is not allowed. Controller already exists.'); + if ($this->nameUtil->nameIsUsed($name)) { + throw new Conflict("Name '{$name}' is already used."); } - $serviceFactory = $this->serviceFactory; - - if ($serviceFactory->checkExists($name)) { - throw new Conflict('Entity name \''.$name.'\' is not allowed.'); - } - - if (in_array($name, $this->forbiddenEntityTypeNameList)) { - throw new Conflict('Entity name \''.$name.'\' is not allowed.'); - } - - if (in_array(strtolower($name), $this->reservedWordList)) { - throw new Conflict('Entity name \''.$name.'\' is not allowed.'); - } - - if ($this->checkRelationshipExists($name)) { - throw new Conflict('Relationship with the same name \''.$name.'\' exists.'); - } - - if (preg_match('/[^a-zA-Z\d]/', $name)) { - throw new Error("Entity name should contain only letters and numbers."); - } - - $firstLatter = $name[0]; - - if (preg_match('/[^A-Z]/', $firstLatter)) { - throw new Error("Entity name should start with an upper case letter."); + if ($this->nameUtil->nameIsNotAllowed($name)) { + throw new Conflict("Entity name '{$name}' is not allowed."); } $normalizedName = Util::normilizeClassName($name); @@ -918,9 +770,9 @@ class EntityManager lcfirst($entity) . $entityForeign; if ( - strlen($relationName) > self::MAX_LINK_NAME + strlen($relationName) > NameUtil::MAX_LINK_NAME_LENGTH ) { - throw new Error("Relation name should not be longer than " . self::MAX_LINK_NAME . "."); + throw new Error("Relation name should not be longer than " . NameUtil::MAX_LINK_NAME_LENGTH . "."); } if (preg_match('/[^a-z]/', $relationName[0])) { @@ -931,7 +783,7 @@ class EntityManager throw new Conflict("Entity with the same name '{$relationName}' exists."); } - if ($this->checkRelationshipExists($relationName)) { + if ($this->nameUtil->relationshipExists($relationName)) { throw new Conflict("Relationship with the same name '{$relationName}' exists."); } } @@ -945,8 +797,8 @@ class EntityManager ->setName($relationName) ->build(); - if (strlen($link) > self::MAX_LINK_NAME || strlen($linkForeign) > self::MAX_LINK_NAME) { - throw new Error("Link name should not be longer than " . self::MAX_LINK_NAME . "."); + if (strlen($link) > NameUtil::MAX_LINK_NAME_LENGTH || strlen($linkForeign) > NameUtil::MAX_LINK_NAME_LENGTH) { + throw new Error("Link name should not be longer than " . NameUtil::MAX_LINK_NAME_LENGTH . "."); } if (is_numeric($link[0]) || is_numeric($linkForeign[0])) { @@ -961,11 +813,11 @@ class EntityManager throw new Error("Link name should start with a lower case letter."); } - if (in_array($link, $this->linkForbiddenNameList)) { + if (in_array($link, NameUtil::LINK_FORBIDDEN_NAME_LIST)) { throw new Conflict("Link name '{$link}' is not allowed."); } - if (in_array($linkForeign, $this->linkForbiddenNameList)) { + if (in_array($linkForeign, NameUtil::LINK_FORBIDDEN_NAME_LIST)) { throw new Conflict("Link name '{$linkForeign}' is not allowed."); } diff --git a/application/Espo/Tools/EntityManager/NameUtil.php b/application/Espo/Tools/EntityManager/NameUtil.php new file mode 100644 index 0000000000..e21242ef77 --- /dev/null +++ b/application/Espo/Tools/EntityManager/NameUtil.php @@ -0,0 +1,233 @@ +metadata = $metadata; + $this->serviceFactory = $serviceFactory; + $this->entityManager = $entityManager; + } + + public function nameIsBad(string $name): bool + { + if (!$name) { + return true; + } + + if (preg_match('/[^a-zA-Z\d]/', $name)) { + return true; + } + + if (preg_match('/[^A-Z]/', $name[0])) { + return true; + } + + return false; + } + + public function nameIsTooShort(string $name): bool + { + return strlen($name) < NameUtil::MIN_ENTITY_NAME_LENGTH; + } + + public function nameIsTooLong(string $name): bool + { + return strlen($name) > NameUtil::MAX_ENTITY_NAME_LENGTH; + } + + public function nameIsNotAllowed(string $name): bool + { + if (in_array($name, self::ENTITY_TYPE_FORBIDDEN_NAME_LIST)) { + return true; + } + + if (in_array(strtolower($name), NameUtil::RESERVED_WORLD_LIST)) { + return true; + } + + if ($name !== Util::normilizeScopeName($name)) { + return true; + } + + return false; + } + + public function nameIsUsed(string $name): bool + { + if ($this->metadata->get(['scopes', $name])) { + return true; + } + + if ($this->metadata->get(['entityDefs', $name])) { + return true; + } + + if ($this->metadata->get(['clientDefs', $name])) { + return true; + } + + if ($this->relationshipExists($name)) { + return true; + } + + if ($this->controllerExists($name)) { + return true; + } + + if ($this->serviceFactory->checkExists($name)) { + return true; + } + + return false; + } + + private function controllerExists(string $name): bool + { + $controllerClassName = 'Espo\\Custom\\Controllers\\' . Util::normilizeClassName($name); + + if (class_exists($controllerClassName)) { + return true; + } + + foreach ($this->metadata->getModuleList() as $moduleName) { + $controllerClassName = + 'Espo\\Modules\\' . $moduleName . '\\Controllers\\' . Util::normilizeClassName($name); + + if (class_exists($controllerClassName)) { + return true; + } + } + + $controllerClassName = 'Espo\\Controllers\\' . Util::normilizeClassName($name); + + if (class_exists($controllerClassName)) { + return true; + } + + return false; + } + + public function relationshipExists(string $name): bool + { + /** @var string[] */ + $scopeList = array_keys($this->metadata->get(['scopes'], [])); + + foreach ($scopeList as $entityType) { + $relationsDefs = $this->entityManager + ->getMetadata() + ->get($entityType, 'relations'); + + if (empty($relationsDefs)) { + continue; + } + + foreach ($relationsDefs as $item) { + if (empty($item['type']) || empty($item['relationName'])) { + continue; + } + + if ( + $item['type'] === Entity::MANY_MANY && + ucfirst($item['relationName']) === ucfirst($name) + ) { + return true; + } + } + } + + return false; + } +} diff --git a/application/Espo/Tools/EntityManager/Rename/ClassType.php b/application/Espo/Tools/EntityManager/Rename/ClassType.php new file mode 100644 index 0000000000..47dac1c3ce --- /dev/null +++ b/application/Espo/Tools/EntityManager/Rename/ClassType.php @@ -0,0 +1,43 @@ + + */ + private $classTypeList = [ + ClassType::ENTITY, + ClassType::CONTROLLER, + ClassType::REPOSITORY, + ClassType::SELECT_MANAGER, + ClassType::SERVICE, + ]; + + /** + * @var array + */ + private $metadataTypeList = [ + MetadataType::ACL_DEFS, + MetadataType::CLIENT_DEFS, + MetadataType::ENTITY_DEFS, + MetadataType::ENTITY_ACL, + MetadataType::FORMULA, + MetadataType::NOTIFICATION_DEFS, + MetadataType::PDF_DEFS, + MetadataType::RECORD_DEFS, + MetadataType::SCOPES, + MetadataType::SELECT_DEFS, + ]; + + private NameUtil $nameUtil; + + private Metadata $metadata; + + private FileManager $fileManager; + + private EntityManager $entityManager; + + private DataManager $dataManager; + + private Config $config; + + private ConfigWriter $configWriter; + + private Language $language; + + private Log $log; + + public function __construct( + NameUtil $nameUtil, + Metadata $metadata, + FileManager $fileManager, + EntityManager $entityManager, + DataManager $dataManager, + Config $config, + ConfigWriter $configWriter, + Language $language, + Log $log + ) { + $this->nameUtil = $nameUtil; + $this->metadata = $metadata; + $this->fileManager = $fileManager; + $this->entityManager = $entityManager; + $this->dataManager = $dataManager; + $this->config = $config; + $this->configWriter = $configWriter; + $this->language = $language; + $this->log = $log; + } + + public function process(string $entityType, string $newName, IO $io): Result + { + $failResult = $this->validate($entityType, $newName); + + if ($failResult) { + return $failResult; + } + + $io->writeLine(' DB table...'); + $this->renameDbTable($entityType, $newName); + + $io->writeLine(' DB many-to-many tables...'); + $this->renameDbManyToMany($entityType, $newName); + + $io->writeLine(' metadata relationships...'); + $this->renameInRelationships($entityType, $newName); + + $io->writeLine(' metadata fields...'); + $this->renameInFields($entityType, $newName); + + $io->writeLine(' php classes...'); + foreach ($this->classTypeList as $classType) { + $this->renameClass($classType, $entityType, $newName); + } + + $io->writeLine(' metadata files...'); + foreach ($this->metadataTypeList as $metadataType) { + $this->renameMetadata($metadataType, $entityType, $newName); + } + + $io->writeLine(' layouts...'); + $this->renameLayouts($entityType, $newName); + + $io->writeLine(' values in DB...'); + $this->changeValuesInDb($entityType, $newName); + + $io->writeLine(' language...'); + $this->renameLanguage($entityType, $newName); + + $io->writeLine(' config parameters...'); + $this->renameInConfig($entityType, $newName); + + $io->writeLine(' rebuild...'); + $this->dataManager->rebuild(); + + return Result::createSuccess(); + } + + private function validate(string $entityType, string $newName): ?Result + { + if (!$this->metadata->get(['scopes', $entityType, 'entity'])) { + return Result::createFail(FailReason::DOES_NOT_EXIST); + } + + if (!$this->metadata->get(['scopes', $entityType, 'isCustom'])) { + return Result::createFail(FailReason::NOT_CUSTOM); + } + + if ($this->nameUtil->nameIsBad($newName)) { + return Result::createFail(FailReason::NAME_BAD); + } + + /** @var non-empty-string $newName */ + + if ($this->nameUtil->nameIsTooLong($newName)) { + return Result::createFail(FailReason::NAME_TOO_LONG); + } + + if ($this->nameUtil->nameIsTooShort($newName)) { + return Result::createFail(FailReason::NAME_TOO_SHORT); + } + + if ($this->nameUtil->nameIsNotAllowed($newName)) { + return Result::createFail(FailReason::NAME_NOT_ALLOWED); + } + + if ($this->nameUtil->nameIsUsed($newName)) { + return Result::createFail(FailReason::NAME_USED); + } + + if (!$this->fileManager->isFile($this->getClassFilePath(ClassType::ENTITY, $entityType))) { + return Result::createFail(FailReason::NOT_CUSTOM); + } + + if (!$this->fileManager->isFile($this->getClassFilePath(ClassType::CONTROLLER, $entityType))) { + return Result::createFail(FailReason::NOT_CUSTOM); + } + + if (!$this->fileManager->isFile($this->getMetadataFilePath(MetadataType::ENTITY_DEFS, $entityType))) { + return Result::createFail(FailReason::NOT_CUSTOM); + } + + if (!$this->fileManager->isFile($this->getMetadataFilePath(MetadataType::SCOPES, $entityType))) { + return Result::createFail(FailReason::NOT_CUSTOM); + } + + if ($this->tableExists($newName)) { + return Result::createFail(FailReason::TABLE_EXISTS); + } + + return null; + } + + private function renameDbManyToMany(string $entityType, string $newName): void + { + $relationList = $this->entityManager + ->getDefs() + ->getEntity($entityType) + ->getRelationList(); + + foreach ($relationList as $relation) { + if (!$relation->isManyToMany()) { + continue; + } + + if (lcfirst($relation->getRelationshipName()) === 'entityTeam') { + continue; + } + + $this->renameDbRelationshipTableColumn( + $relation->getRelationshipName(), + $entityType . 'Id', + $newName . 'Id' + ); + } + } + + /** + * @param ClassType::* $classType + */ + private function renameClass(string $classType, string $name, string $newName): void + { + $path = $this->getClassFilePath($classType, $name); + $newPath = $this->getClassFilePath($classType, $newName); + + if (!$this->fileManager->isFile($path)) { + return; + } + + $contents = $this->fileManager->getContents($path); + + $replaceFrom = [ + "class {$name}", + "'{$name}'", + ]; + + $replaceTo = [ + "class {$newName}", + "'{$newName}'", + ]; + + $newContents = str_replace($replaceFrom, $replaceTo, $contents); + + $this->fileManager->putContents($newPath, $newContents); + $this->fileManager->removeFile($path); + } + + /** + * @param MetadataType::* $metadataType + */ + private function renameMetadata(string $metadataType, string $name, string $newName): void + { + $path = $this->getMetadataFilePath($metadataType, $name); + $newPath = $this->getMetadataFilePath($metadataType, $newName); + + if (!$this->fileManager->isFile($path)) { + return; + } + + $contents = $this->fileManager->getContents($path); + + $replaceFrom = []; + $replaceTo = []; + + $newContents = str_replace($replaceFrom, $replaceTo, $contents); + + $this->fileManager->putContents($newPath, $newContents); + $this->fileManager->removeFile($path); + } + + /** + * @param ClassType::* $classType + */ + private function getClassFilePath(string $classType, string $name): string + { + return "custom/Espo/Custom/{$classType}/{$name}.php"; + } + + /** + * @param MetadataType::* $metadataType + */ + private function getMetadataFilePath(string $metadataType, string $name): string + { + return "custom/Espo/Custom/Resources/metadata/{$metadataType}/{$name}.json"; + } + + private function renameLayouts(string $name, string $newName): void + { + $fromPath = "custom/Espo/Custom/Resources/layouts/{$name}"; + $toPath = "custom/Espo/Custom/Resources/layouts/{$newName}"; + + rename($fromPath, $toPath); + } + + private function tableExists(string $newName): bool + { + $table = Util::camelCaseToUnderscore($newName); + + if (self::dbNameSanitize($table) !== $table) { + throw new RuntimeException(); + } + + $sql = "SHOW TABLES LIKE '{$table}'"; + + $sth = $this->entityManager->getSqlExecutor()->execute($sql); + + if ($sth->fetch()) { + return true; + } + + return false; + } + + private function renameDbTable(string $name, string $newName): void + { + $table = Util::camelCaseToUnderscore($name); + $newTable = Util::camelCaseToUnderscore($newName); + + if (self::dbNameSanitize($table) !== $table) { + throw new RuntimeException(); + } + + if (self::dbNameSanitize($newTable) !== $newTable) { + throw new RuntimeException(); + } + + $sql = "RENAME TABLE `{$table}` TO `{$newTable}`"; + + $this->entityManager->getSqlExecutor()->execute($sql); + } + + private function renameDbRelationshipTableColumn(string $entityType, string $name, string $newName): void + { + $table = Util::camelCaseToUnderscore($entityType); + $column = Util::camelCaseToUnderscore($name); + $newColumn = Util::camelCaseToUnderscore($newName); + + if (self::dbNameSanitize($table) !== $table) { + throw new RuntimeException(); + } + + if (self::dbNameSanitize($column) !== $column) { + throw new RuntimeException(); + } + + if (self::dbNameSanitize($newColumn) !== $newColumn) { + throw new RuntimeException(); + } + + $sql = "ALTER TABLE `{$table}` CHANGE `{$column}` `{$newColumn}` VARCHAR(24)"; + + try { + $this->entityManager->getSqlExecutor()->execute($sql); + } + catch (Throwable $e) { + $msg = $e->getMessage(); + + $this->log->error("Entity-rename: Rename relationship column failed: {$msg}."); + } + } + + private static function dbNameSanitize(string $name): string + { + return preg_replace('/[^A-Za-z0-9_]+/', '', $name) ?? ''; + } + + private function renameLanguage(string $entityType, string $newName): void + { + $label = $this->language->translateLabel($entityType, 'scopeNames'); + $labelPlural = $this->language->translateLabel($entityType, 'scopeNamesPlural'); + + $this->language->delete('Global', 'scopeNames', $entityType); + $this->language->delete('Global', 'scopeNamesPlural', $entityType); + $this->language->set('Global', 'scopeNames', $newName, $label); + $this->language->set('Global', 'scopeNamesPlural', $newName, $labelPlural); + + $this->language->save(); + + $langList = $this->fileManager->getDirList('custom/Espo/Custom/Resources/i18n'); + + foreach ($langList as $lang) { + $fromPath = "custom/Espo/Custom/Resources/i18n/{$lang}/{$entityType}.json"; + $toPath = "custom/Espo/Custom/Resources/i18n/{$lang}/{$newName}.json"; + + if (!$this->fileManager->isFile($fromPath)) { + continue; + } + + $contents = $this->fileManager->getContents($fromPath); + + $this->fileManager->putContents($toPath, $contents); + $this->fileManager->removeFile($fromPath); + } + } + + private function renameInConfig(string $entityType, string $newName): void + { + /** @var string[] */ + $entityTypeListParamList = $this->metadata->get(['app', 'config', 'entityTypeListParamList']) ?? []; + + foreach ($entityTypeListParamList as $param) { + /** @var ?string[] */ + $list = $this->config->get($param); + + if ($list === null) { + continue; + } + + $index = array_search($entityType, $list, true); + + if ($index === false) { + continue; + } + + $list[$index] = $newName; + + $this->configWriter->set($param, $list); + } + + $this->configWriter->save(); + } + + private function renameInRelationships(string $entityType, string $newName): void + { + /** @var string[] */ + $entityTypeList = array_keys($this->metadata->get(['entityDefs']) ?? []); + + foreach ($entityTypeList as $itemEntityType) { + $this->renameInRelationshipsEntity($itemEntityType, $entityType, $newName); + } + + $this->metadata->save(); + } + + private function renameInRelationshipsEntity(string $entityType, string $fromEntityType, string $toEntityType): void + { + /** @var array> */ + $linkDefs = $this->metadata->get(['entityDefs', $entityType, 'links']) ?? []; + + foreach ($linkDefs as $link => $defs) { + $itemEntityType = $defs['entity'] ?? null; + + if ($itemEntityType !== $fromEntityType) { + continue; + } + + $this->metadata->set('entityDefs', $entityType, [ + 'links' => [ + $link => [ + 'entity' => $toEntityType, + ] + ] + ]); + } + } + + private function renameInFields(string $entityType, string $newName): void + { + $entityList = $this->entityManager + ->getDefs() + ->getEntityList(); + + foreach ($entityList as $entityDefs) { + foreach ($entityDefs->getFieldNameList() as $field) { + $this->renameInFieldsField($entityDefs->getName(), $field, $entityType, $newName); + } + } + + $this->metadata->save(); + } + + private function renameInFieldsField(string $entityType, string $field, string $from, string $to): void + { + $defs = $this->entityManager + ->getDefs() + ->getEntity($entityType) + ->getField($field); + + if ($defs->getType() === 'linkParent') { + $this->renameInLinkParentField($entityType, $defs->getName(), $from, $to); + + return; + } + } + + private function renameInLinkParentField(string $entityType, string $field, string $from, string $to): void + { + /** @var string[] */ + $list = $this->metadata->get(['entityDefs', $entityType, 'fields', $field, 'entityList']) ?? []; + + $index = array_search($from, $list, true); + + if ($index === false) { + return; + } + + $list[$index] = $to; + + $this->metadata->set('entityDefs', $entityType, [ + 'fields' => [ + $field => ['entityList' => $list] + ] + ]); + } + + private function changeValuesInDb(string $from, string $to): void + { + $this->changeValuesInDbFields($from, $to); + $this->changeValuesInDbRoles($from, $to); + + $query1 = $this->entityManager + ->getQueryBuilder() + ->update() + ->in('EntityTeam') + ->set(['entityType' => $to]) + ->where(['entityType' => $from]) + ->build(); + + $this->entityManager->getQueryExecutor()->execute($query1); + + $query2 = $this->entityManager + ->getQueryBuilder() + ->update() + ->in('EntityEmailAddress') + ->set(['entityType' => $to]) + ->where(['entityType' => $from]) + ->build(); + + $this->entityManager->getQueryExecutor()->execute($query2); + + $query3 = $this->entityManager + ->getQueryBuilder() + ->update() + ->in('EntityPhoneNumber') + ->set(['entityType' => $to]) + ->where(['entityType' => $from]) + ->build(); + + $this->entityManager->getQueryExecutor()->execute($query3); + } + + private function changeValuesInDbFields(string $from, string $to): void + { + $entityList = $this->entityManager + ->getDefs() + ->getEntityList(); + + foreach ($entityList as $entityDefs) { + foreach ($entityDefs->getFieldNameList() as $field) { + $this->changeValuesInDbFieldsField($entityDefs->getName(), $field, $from, $to); + } + } + } + + private function changeValuesInDbFieldsField(string $entityType, string $field, string $from, string $to): void + { + $defs = $this->entityManager + ->getDefs() + ->getEntity($entityType) + ->getField($field); + + if ($defs->getType() === 'linkParent') { + $this->changeValuesInDbFieldsFieldLinkParent($entityType, $defs->getName(), $from, $to); + + return; + } + } + + private function changeValuesInDbFieldsFieldLinkParent( + string $entityType, + string $field, + string $from, + string $to + ): void { + + $typeAttribute = $field . 'Type'; + + $query = $this->entityManager + ->getQueryBuilder() + ->update() + ->in($entityType) + ->set([$typeAttribute => $to]) + ->where([$typeAttribute => $from]) + ->build(); + + try { + $this->entityManager->getQueryExecutor()->execute($query); + } + catch (Throwable $e) { + $msg = $e->getMessage(); + + $this->log->error("Entity-rename: Update values in link-parent field {$entityType}.{$field}: {$msg}."); + } + } + + private function changeValuesInDbRoles(string $from, string $to): void + { + $roleList = $this->entityManager + ->getRDBRepository(\Espo\Entities\Role::ENTITY_TYPE) + ->find(); + + foreach ($roleList as $role) { + /** @var \stdClass */ + $data = $role->get('data'); + /** @var \stdClass */ + $fieldData = $role->get('fieldData'); + + if (isset($data->$from)) { + $data->$to = $data->$from; + unset($data->$from); + $role->set('data', $data); + } + + if (isset($fieldData->$from)) { + $fieldData->$to = $fieldData->$from; + unset($fieldData->$from); + $role->set('fieldData', $fieldData); + } + + $this->entityManager->saveEntity($role); + } + + $portalRoleList = $this->entityManager + ->getRDBRepository(\Espo\Entities\PortalRole::ENTITY_TYPE) + ->find(); + + foreach ($portalRoleList as $role) { + /** @var \stdClass */ + $data = $role->get('data'); + /** @var \stdClass */ + $fieldData = $role->get('fieldData'); + + if (isset($data->$from)) { + $data->$to = $data->$from; + unset($data->$from); + $role->set('data', $data); + } + + if (isset($fieldData->$from)) { + $fieldData->$to = $fieldData->$from; + unset($fieldData->$from); + $role->set('fieldData', $fieldData); + } + + $this->entityManager->saveEntity($role); + } + } +} diff --git a/application/Espo/Tools/EntityManager/Rename/Result.php b/application/Espo/Tools/EntityManager/Rename/Result.php new file mode 100644 index 0000000000..96f7692cd4 --- /dev/null +++ b/application/Espo/Tools/EntityManager/Rename/Result.php @@ -0,0 +1,71 @@ +isFail = true; + $obj->failReason = $reason; + + return $obj; + } + + public function isFail(): bool + { + return $this->isFail; + } + + /** + * @return FailReason::*|null + */ + public function getFailReason(): ?string + { + return $this->failReason; + } +} diff --git a/tests/unit/Espo/Core/Console/IOTest.php b/tests/unit/Espo/Core/Console/IOTest.php new file mode 100644 index 0000000000..95bb910dc6 --- /dev/null +++ b/tests/unit/Espo/Core/Console/IOTest.php @@ -0,0 +1,46 @@ +assertEquals(0, $io->getExitStatus()); + + $io->setExitStatus(1); + + $this->assertEquals(1, $io->getExitStatus()); + } +}