diff --git a/application/Espo/Core/DataManager.php b/application/Espo/Core/DataManager.php index 55354f9662..898e3cc105 100644 --- a/application/Espo/Core/DataManager.php +++ b/application/Espo/Core/DataManager.php @@ -33,13 +33,13 @@ use Espo\Core\Utils\Config\MissingDefaultParamsSaver as ConfigMissingDefaultPara use Espo\Core\Exceptions\Error; use Espo\Core\ORM\EntityManagerProxy; +use Espo\Core\Utils\Database\Schema\SchemaManagerProxy; use Espo\Core\Utils\File\Manager as FileManager; use Espo\Core\Utils\Metadata; use Espo\Core\Utils\Util; use Espo\Core\Utils\Config; use Espo\Core\Utils\Config\ConfigWriter; use Espo\Core\Utils\Metadata\OrmMetadataData; -use Espo\Core\Utils\Database\Schema\SchemaProxy; use Espo\Core\Utils\Log; use Espo\Core\Utils\Module; use Espo\Core\Rebuild\RebuildActionProcessor; @@ -51,63 +51,37 @@ use Throwable; */ class DataManager { - private Config $config; - private ConfigWriter $configWriter; - private EntityManagerProxy $entityManager; - private Metadata $metadata; - private OrmMetadataData $ormMetadataData; - private HookManager $hookManager; - private SchemaProxy $schemaProxy; - private Log $log; - private Module $module; - private RebuildActionProcessor $rebuildActionProcessor; - private ConfigMissingDefaultParamsSaver $configMissingDefaultParamsSaver; - private FileManager $fileManager; - private string $cachePath = 'data/cache'; public function __construct( - EntityManagerProxy $entityManager, - Config $config, - ConfigWriter $configWriter, - Metadata $metadata, - OrmMetadataData $ormMetadataData, - HookManager $hookManager, - SchemaProxy $schemaProxy, - Log $log, - Module $module, - RebuildActionProcessor $rebuildActionProcessor, - ConfigMissingDefaultParamsSaver $configMissingDefaultParamsSaver, - FileManager $fileManager - ) { - $this->entityManager = $entityManager; - $this->config = $config; - $this->configWriter = $configWriter; - $this->metadata = $metadata; - $this->ormMetadataData = $ormMetadataData; - $this->hookManager = $hookManager; - $this->schemaProxy = $schemaProxy; - $this->log = $log; - $this->module = $module; - $this->rebuildActionProcessor = $rebuildActionProcessor; - $this->configMissingDefaultParamsSaver = $configMissingDefaultParamsSaver; - $this->fileManager = $fileManager; - } + private EntityManagerProxy $entityManager, + private Config $config, + private ConfigWriter $configWriter, + private Metadata $metadata, + private OrmMetadataData $ormMetadataData, + private HookManager $hookManager, + private SchemaManagerProxy $schemaManager, + private Log $log, + private Module $module, + private RebuildActionProcessor $rebuildActionProcessor, + private ConfigMissingDefaultParamsSaver $configMissingDefaultParamsSaver, + private FileManager $fileManager + ) {} /** * Rebuild the system with metadata, database and cache clearing. * - * @param ?string[] $entityList + * @param ?string[] $entityTypeList * @throws Error */ - public function rebuild(?array $entityList = null): void + public function rebuild(?array $entityTypeList = null): void { $this->clearCache(); $this->disableHooks(); $this->checkModules(); $this->rebuildMetadata(); $this->populateConfigParameters(); - $this->rebuildDatabase($entityList); + $this->rebuildDatabase($entityTypeList); $this->rebuildActionProcessor->process(); $this->configMissingDefaultParamsSaver->process(); $this->enableHooks(); @@ -134,15 +108,15 @@ class DataManager /** * Rebuild database. * - * @param ?string[] $entityList + * @param ?string[] $entityTypeList * @throws Error */ - public function rebuildDatabase(?array $entityList = null): void + public function rebuildDatabase(?array $entityTypeList = null): void { - $schema = $this->schemaProxy; + $schemaManager = $this->schemaManager; try { - $result = $schema->rebuild($entityList); + $result = $schemaManager->rebuild($entityTypeList); } catch (Throwable $e) { $result = false; @@ -157,7 +131,7 @@ class DataManager throw new Error("Error while rebuilding database. See log file for details."); } - $databaseType = strtolower($schema->getDatabaseHelper()->getDatabaseType()); + $databaseType = strtolower($schemaManager->getDatabaseHelper()->getType()); if ( !$this->config->get('actualDatabaseType') || @@ -166,7 +140,7 @@ class DataManager $this->configWriter->set('actualDatabaseType', $databaseType); } - $databaseVersion = $schema->getDatabaseHelper()->getDatabaseVersion(); + $databaseVersion = $schemaManager->getDatabaseHelper()->getVersion(); if ( !$this->config->get('actualDatabaseVersion') || @@ -181,24 +155,14 @@ class DataManager /** * Rebuild metadata. - * - * @throws Error */ public function rebuildMetadata(): void { - $metadata = $this->metadata; - - $metadata->init(true); - - $ormData = $this->ormMetadataData->getData(true); - + $this->metadata->init(true); + $this->ormMetadataData->reload(); $this->entityManager->getMetadata()->updateData(); $this->updateCacheTimestamp(); - - if (empty($ormData)) { - throw new Error("Error while rebuilding metadata. See log file for details."); - } } /** diff --git a/application/Espo/Core/ORM/DatabaseParamsFactory.php b/application/Espo/Core/ORM/DatabaseParamsFactory.php new file mode 100644 index 0000000000..94b722cdce --- /dev/null +++ b/application/Espo/Core/ORM/DatabaseParamsFactory.php @@ -0,0 +1,95 @@ +config; + + if (!$config->get('database')) { + throw new RuntimeException('No database params in config.'); + } + + $databaseParams = DatabaseParams::create() + ->withHost($config->get('database.host')) + ->withPort($config->get('database.port') ? (int) $config->get('database.port') : null) + ->withName($config->get('database.dbname')) + ->withUsername($config->get('database.user')) + ->withPassword($config->get('database.password')) + ->withCharset($config->get('database.charset') ?? self::DEFAULT_CHARSET) + ->withPlatform($config->get('database.platform')) + ->withSslCa($config->get('database.sslCA')) + ->withSslCert($config->get('database.sslCert')) + ->withSslKey($config->get('database.sslKey')) + ->withSslCaPath($config->get('database.sslCAPath')) + ->withSslCipher($config->get('database.sslCipher')) + ->withSslVerifyDisabled($config->get('database.sslVerifyDisabled') ?? false); + + if (!$databaseParams->getPlatform()) { + $databaseParams = $databaseParams->withPlatform(self::DEFAULT_PLATFORM); + } + + return $databaseParams; + } + + /** + * @param array $params + */ + public function createWithMergedAssoc(array $params): DatabaseParams + { + $configParams = $this->create(); + + return DatabaseParams::create() + ->withHost($params['host'] ?? $configParams->getHost()) + ->withPort(isset($params['port']) ? (int) $params['port'] : $configParams->getPort()) + ->withName($params['dbname'] ?? $configParams->getName()) + ->withUsername($params['user'] ?? $configParams->getUsername()) + ->withPassword($params['password'] ?? $configParams->getPassword()) + ->withCharset($params['charset'] ?? $configParams->getCharset()) + ->withPlatform($params['platform'] ?? $configParams->getPlatform()) + ->withSslCa($params['sslCA'] ?? $configParams->getSslCa()) + ->withSslCert($params['sslCert'] ?? $configParams->getSslCert()) + ->withSslKey($params['sslKey'] ?? $configParams->getSslKey()) + ->withSslCaPath($params['sslCAPath'] ?? $configParams->getSslCaPath()) + ->withSslCipher($params['sslCipher'] ?? $configParams->getSslCipher()) + ->withSslVerifyDisabled($params['sslVerifyDisabled'] ?? $configParams->isSslVerifyDisabled()); + } +} diff --git a/application/Espo/Core/ORM/EntityManagerFactory.php b/application/Espo/Core/ORM/EntityManagerFactory.php index 926b6fde58..eecef3443d 100644 --- a/application/Espo/Core/ORM/EntityManagerFactory.php +++ b/application/Espo/Core/ORM/EntityManagerFactory.php @@ -31,7 +31,6 @@ namespace Espo\Core\ORM; use Espo\Core\ORM\PDO\PDOFactoryFactory; use Espo\Core\ORM\QueryComposer\QueryComposerFactory; -use Espo\Core\Utils\Config; use Espo\Core\InjectableFactory; use Espo\Core\Binding\BindingContainerBuilder; use Espo\Core\ORM\QueryComposer\Part\FunctionConverterFactory; @@ -52,18 +51,12 @@ use RuntimeException; class EntityManagerFactory { - /** @var array */ - private $driverPlatformMap = [ - 'pdo_mysql' => 'Mysql', - 'mysqli' => 'Mysql', - ]; - public function __construct( - private Config $config, private InjectableFactory $injectableFactory, private MetadataDataProvider $metadataDataProvider, private EventDispatcher $eventDispatcher, - private PDOFactoryFactory $pdoFactoryFactory + private PDOFactoryFactory $pdoFactoryFactory, + private DatabaseParamsFactory $databaseParamsFactory ) {} public function create(): EntityManager @@ -140,41 +133,10 @@ class EntityManagerFactory private function createDatabaseParams(): DatabaseParams { - $config = $this->config; - - $databaseParams = DatabaseParams::create() - ->withHost($config->get('database.host')) - ->withPort($config->get('database.port') ? (int) $config->get('database.port') : null) - ->withName($config->get('database.dbname')) - ->withUsername($config->get('database.user')) - ->withPassword($config->get('database.password')) - ->withCharset($config->get('database.charset') ?? 'utf8') - ->withPlatform($config->get('database.platform')) - ->withSslCa($config->get('database.sslCA')) - ->withSslCert($config->get('database.sslCert')) - ->withSslKey($config->get('database.sslKey')) - ->withSslCaPath($config->get('database.sslCAPath')) - ->withSslCipher($config->get('database.sslCipher')) - ->withSslVerifyDisabled($config->get('database.sslVerifyDisabled') ?? false); + $databaseParams = $this->databaseParamsFactory->create(); if (!$databaseParams->getName()) { - throw new RuntimeException('No database name specified.'); - } - - if (!$databaseParams->getPlatform()) { - $driver = $config->get('database.driver'); - - if (!$driver) { - throw new RuntimeException('No database driver specified.'); - } - - $platform = $this->driverPlatformMap[$driver] ?? null; - - if (!$platform) { - throw new RuntimeException("Database driver '{$driver}' is not supported."); - } - - $databaseParams = $databaseParams->withPlatform($platform); + throw new RuntimeException('No database name specified in config.'); } return $databaseParams; diff --git a/application/Espo/Core/Rebuild/Actions/AddSystemUser.php b/application/Espo/Core/Rebuild/Actions/AddSystemUser.php new file mode 100644 index 0000000000..7b354615eb --- /dev/null +++ b/application/Espo/Core/Rebuild/Actions/AddSystemUser.php @@ -0,0 +1,64 @@ +entityManager->getRDBRepositoryByClass(User::class); + + $user = $repository->getById($userId); + + if ($user) { + return; + } + + /** @var array $attributes */ + $attributes = $this->config->get('systemUserAttributes'); + + $user = $repository->getNew(); + $user->set($attributes); + $repository->save($user); + } +} diff --git a/application/Espo/Core/Utils/Database/Schema/rebuildActions/Currency.php b/application/Espo/Core/Rebuild/Actions/CurrencyRates.php similarity index 81% rename from application/Espo/Core/Utils/Database/Schema/rebuildActions/Currency.php rename to application/Espo/Core/Rebuild/Actions/CurrencyRates.php index 8dd98d8ea7..95b20cface 100644 --- a/application/Espo/Core/Utils/Database/Schema/rebuildActions/Currency.php +++ b/application/Espo/Core/Rebuild/Actions/CurrencyRates.php @@ -27,19 +27,17 @@ * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. ************************************************************************/ -namespace Espo\Core\Utils\Database\Schema\rebuildActions; +namespace Espo\Core\Rebuild\Actions; +use Espo\Core\Rebuild\RebuildAction; use Espo\Core\Utils\Currency\DatabasePopulator; -class Currency extends \Espo\Core\Utils\Database\Schema\BaseRebuildActions +class CurrencyRates implements RebuildAction { - /** - * @return void - */ - public function afterRebuild() - { - $populator = new DatabasePopulator($this->getConfig(), $this->getEntityManager()); + public function __construct(private DatabasePopulator $databasePopulator) {} - $populator->process(); + public function process(): void + { + $this->databasePopulator->process(); } } diff --git a/application/Espo/Core/Upgrades/Actions/Base.php b/application/Espo/Core/Upgrades/Actions/Base.php index c3151dfe01..7d312e8210 100644 --- a/application/Espo/Core/Upgrades/Actions/Base.php +++ b/application/Espo/Core/Upgrades/Actions/Base.php @@ -368,11 +368,11 @@ abstract class Base //check database version if (isset($manifest['database'])) { $databaseHelper = $this->getDatabaseHelper(); - $databaseType = $databaseHelper->getDatabaseType(); + $databaseType = $databaseHelper->getType(); $databaseTypeLc = strtolower($databaseType); if (isset($manifest['database'][$databaseTypeLc])) { - $databaseVersion = $databaseHelper->getDatabaseVersion(); + $databaseVersion = $databaseHelper->getVersion(); if ($databaseVersion) { $res &= $this->checkVersions( diff --git a/application/Espo/Core/Utils/Currency/DatabasePopulator.php b/application/Espo/Core/Utils/Currency/DatabasePopulator.php index 329f82a9f1..0489e1e1c6 100644 --- a/application/Espo/Core/Utils/Currency/DatabasePopulator.php +++ b/application/Espo/Core/Utils/Currency/DatabasePopulator.php @@ -29,6 +29,7 @@ namespace Espo\Core\Utils\Currency; +use Espo\Entities\Currency; use Espo\ORM\EntityManager; use Espo\Core\Utils\Config; @@ -37,20 +38,14 @@ use Espo\Core\Utils\Config; */ class DatabasePopulator { - private Config $config; - - private EntityManager $entityManager; - - public function __construct(Config $config, EntityManager $entityManager) - { - $this->config = $config; - $this->entityManager = $entityManager; - } + public function __construct( + private Config $config, + private EntityManager $entityManager) + {} public function process(): void { $defaultCurrency = $this->config->get('defaultCurrency'); - $baseCurrency = $this->config->get('baseCurrency'); $currencyRates = $this->config->get('currencyRates'); @@ -62,13 +57,13 @@ class DatabasePopulator $delete = $this->entityManager->getQueryBuilder() ->delete() - ->from('Currency') + ->from(Currency::ENTITY_TYPE) ->build(); $this->entityManager->getQueryExecutor()->execute($delete); foreach ($currencyRates as $currencyName => $rate) { - $this->entityManager->createEntity('Currency', [ + $this->entityManager->createEntity(Currency::ENTITY_TYPE, [ 'id' => $currencyName, 'rate' => $rate, ]); @@ -76,8 +71,8 @@ class DatabasePopulator } /** - * @param array $currencyRates - * @return array + * @param array $currencyRates + * @return array */ private function exchangeRates(string $baseCurrency, string $defaultCurrency, array $currencyRates): array { diff --git a/application/Espo/Core/Utils/Database/ConfigDataProvider.php b/application/Espo/Core/Utils/Database/ConfigDataProvider.php new file mode 100644 index 0000000000..afe413b947 --- /dev/null +++ b/application/Espo/Core/Utils/Database/ConfigDataProvider.php @@ -0,0 +1,44 @@ +config->get('database.platform') ?? self::DEFAULT_PLATFORM; + } +} diff --git a/application/Espo/Core/Utils/Database/DBAL/ConnectionFactoryFactory.php b/application/Espo/Core/Utils/Database/DBAL/ConnectionFactoryFactory.php index 881081e63c..2376aea513 100644 --- a/application/Espo/Core/Utils/Database/DBAL/ConnectionFactoryFactory.php +++ b/application/Espo/Core/Utils/Database/DBAL/ConnectionFactoryFactory.php @@ -45,7 +45,8 @@ class ConnectionFactoryFactory public function create(string $platform, PDO $pdo): ConnectionFactory { /** @var ?class-string $className */ - $className = $this->metadata->get(['app', 'database', 'dbalConnectionFactoryClassNameMap', $platform]); + $className = $this->metadata + ->get(['app', 'database', 'platforms', $platform, 'dbalConnectionFactoryClassName']); if (!$className) { throw new RuntimeException("No DBAL ConnectionFactory for {$platform}."); diff --git a/application/Espo/Core/Utils/Database/Converter.php b/application/Espo/Core/Utils/Database/DetailsProvider.php similarity index 83% rename from application/Espo/Core/Utils/Database/Converter.php rename to application/Espo/Core/Utils/Database/DetailsProvider.php index ec7d604742..7e11fa7056 100644 --- a/application/Espo/Core/Utils/Database/Converter.php +++ b/application/Espo/Core/Utils/Database/DetailsProvider.php @@ -29,18 +29,13 @@ namespace Espo\Core\Utils\Database; -/** - * ORM converter wrapper. - */ -class Converter +interface DetailsProvider { - public function __construct(private Orm\Converter $ormConverter) {} + public function getType(): string; - /** - * @return array> - */ - public function process() - { - return $this->ormConverter->process(); - } + public function getVersion(): string; + + public function getServerVersion(): string; + + public function getParam(string $name): ?string; } diff --git a/application/Espo/Core/Utils/Database/DetailsProviderFactory.php b/application/Espo/Core/Utils/Database/DetailsProviderFactory.php new file mode 100644 index 0000000000..fc3895595b --- /dev/null +++ b/application/Espo/Core/Utils/Database/DetailsProviderFactory.php @@ -0,0 +1,61 @@ + $className */ + $className = $this->metadata + ->get(['app', 'database', 'platforms', $platform, 'detailsProviderClassName']); + + if (!$className) { + throw new RuntimeException("No Details-Provider for {$platform}."); + } + + $binding = BindingContainerBuilder::create() + ->bindInstance(PDO::class, $pdo) + ->build(); + + return $this->injectableFactory->createWithBinding($className, $binding); + } +} diff --git a/application/Espo/Core/Utils/Database/DetailsProviders/MysqlDetailsProvider.php b/application/Espo/Core/Utils/Database/DetailsProviders/MysqlDetailsProvider.php new file mode 100644 index 0000000000..ee4e838b70 --- /dev/null +++ b/application/Espo/Core/Utils/Database/DetailsProviders/MysqlDetailsProvider.php @@ -0,0 +1,107 @@ +getFullDatabaseVersion() ?? ''; + + if (preg_match('/mariadb/i', $version)) { + return self::TYPE_MARIADB; + } + + return self::TYPE_MYSQL; + } + + public function getVersion(): string + { + $fullVersion = $this->getFullDatabaseVersion() ?? ''; + + if (preg_match('/[0-9]+\.[0-9]+\.[0-9]+/', $fullVersion, $match)) { + return $match[0]; + } + + return '0.0.0'; + } + + public function getServerVersion(): string + { + return (string) $this->getParam('version'); + } + + public function getParam(string $name): ?string + { + $sql = "SHOW VARIABLES LIKE :param";; + + $sth = $this->pdo->prepare($sql); + $sth->execute([':param' => $name]); + + $row = $sth->fetch(PDO::FETCH_NUM); + + $index = 1; + + $value = $row[$index] ?: null; + + if ($value === null) { + return null; + } + + return (string) $value; + } + + private function getFullDatabaseVersion(): ?string + { + $sql = "select version()"; + + $sth = $this->pdo->prepare($sql); + $sth->execute(); + + /** @var string|null|false $result */ + $result = $sth->fetchColumn(); + + if ($result === false || $result === null) { + return null; + } + + return $result; + } +} diff --git a/application/Espo/Core/Utils/Database/Helper.php b/application/Espo/Core/Utils/Database/Helper.php index e5f9a963cb..b46877379c 100644 --- a/application/Espo/Core/Utils/Database/Helper.php +++ b/application/Espo/Core/Utils/Database/Helper.php @@ -29,36 +29,30 @@ namespace Espo\Core\Utils\Database; +use Doctrine\DBAL\Connection as DbalConnection; + +use Espo\Core\ORM\DatabaseParamsFactory; use Espo\Core\ORM\PDO\PDOFactoryFactory; -use Espo\Core\Utils\Config; use Espo\Core\Utils\Database\DBAL\ConnectionFactoryFactory as DBALConnectionFactoryFactory; use Espo\ORM\DatabaseParams; -use Doctrine\DBAL\Connection as DBALConnection; - use PDO; use RuntimeException; class Helper { - public const TYPE_MYSQL = 'MySQL'; - public const TYPE_MARIADB = 'MariaDB'; - public const TYPE_POSTGRESQL = 'PostgreSQL'; - - private const DEFAULT_PLATFORM = 'Mysql'; - - private const DEFAULT_INDEX_LIMIT = 1000; - - private ?DBALConnection $dbalConnection = null; - private ?PDO $pdoConnection = null; + private ?DbalConnection $dbalConnection = null; + private ?PDO $pdo = null; public function __construct( - private Config $config, private PDOFactoryFactory $pdoFactoryFactory, - private DBALConnectionFactoryFactory $dbalConnectionFactoryFactory + private DBALConnectionFactoryFactory $dbalConnectionFactoryFactory, + private ConfigDataProvider $configDataProvider, + private DetailsProviderFactory $detailsProviderFactory, + private DatabaseParamsFactory $databaseParamsFactory ) {} - public function getDbalConnection(): DBALConnection + public function getDbalConnection(): DbalConnection { if (!isset($this->dbalConnection)) { $this->dbalConnection = $this->createDbalConnection(); @@ -67,251 +61,94 @@ class Helper return $this->dbalConnection; } - public function getPdoConnection(): PDO + public function getPDO(): PDO { - if (!isset($this->pdoConnection)) { - $this->pdoConnection = $this->createPdoConnection(); + if (!isset($this->pdo)) { + $this->pdo = $this->createPDO(); } - return $this->pdoConnection; + return $this->pdo; } - public function setPdoConnection(PDO $pdoConnection): void + /** + * Clone with another PDO connection. + */ + public function withPDO(PDO $pdo): self { - $this->pdoConnection = $pdoConnection; + $obj = clone $this; + $obj->pdo = $pdo; + $obj->dbalConnection = null; + + return $obj; } - public function createDbalConnection(): DBALConnection + /** + * Create a PDO connection. + */ + public function createPDO(?DatabaseParams $params = null, bool $skipDatabaseName = false): PDO { - /** @var ?array $params */ - $params = $this->config->get('database'); + $params = $params ?? $this->databaseParamsFactory->create(); - if (empty($params)) { - throw new RuntimeException('Database params cannot be empty for DBAL connection.'); + if ($skipDatabaseName) { + $params = $params->withName(null); } - $databaseParams = $this->createDatabaseParams($params); + return $this->pdoFactoryFactory + ->create($params->getPlatform() ?? '') + ->create($params); + } - $platform = $databaseParams->getPlatform() ?? self::DEFAULT_PLATFORM; + private function createDbalConnection(): DbalConnection + { + $params = $this->databaseParamsFactory->create(); + + $platform = $params->getPlatform(); + + if (!$platform) { + throw new RuntimeException("No database platform."); + } return $this->dbalConnectionFactoryFactory - ->create($platform, $this->getPdoConnection()) - ->create($databaseParams); - } - - /** - * Create PDO connection. - * - * @param array $params - */ - public function createPdoConnection(array $params = [], bool $skipDatabaseName = false): PDO - { - $params = array_merge( - $this->config->get('database') ?? [], - $params - ); - - if ($skipDatabaseName && isset($params['dbname'])) { - unset($params['dbname']); - } - - $databaseParams = $this->createDatabaseParams($params); - - $platform = $databaseParams->getPlatform(); - - $pdoFactory = $this->pdoFactoryFactory->create($platform ?? ''); - - return $pdoFactory->create($databaseParams); - } - - /** - * @param array $params - * @throws RuntimeException - */ - private function createDatabaseParams(array $params): DatabaseParams - { - $databaseParams = DatabaseParams::create() - ->withHost($params['host'] ?? null) - ->withPort(isset($params['port']) ? (int) $params['port'] : null) - ->withName($params['dbname'] ?? null) - ->withUsername($params['user'] ?? null) - ->withPassword($params['password'] ?? null) - ->withCharset($params['charset'] ?? 'utf8') - ->withPlatform($params['platform'] ?? null) - ->withSslCa($params['sslCA'] ?? null) - ->withSslCert($params['sslCert'] ?? null) - ->withSslKey($params['sslKey'] ?? null) - ->withSslCaPath($params['sslCAPath'] ?? null) - ->withSslCipher($params['sslCipher'] ?? null) - ->withSslVerifyDisabled($params['sslVerifyDisabled'] ?? false); - - if (!$databaseParams->getPlatform()) { - $databaseParams = $databaseParams->withPlatform(self::DEFAULT_PLATFORM); - } - - return $databaseParams; - } - - /** - * Get maximum index length. If a table name is null, then get a value for all database tables. - */ - public function getMaxIndexLength(?string $tableName = null): int - { - $databaseType = $this->getDatabaseType(); - - if ($databaseType === self::TYPE_POSTGRESQL) { - return 2704; // @todo Revise. - } - - $tableEngine = $this->getTableEngine($tableName); - - if (!$tableEngine) { - return self::DEFAULT_INDEX_LIMIT; - } - - switch ($tableEngine) { - case 'InnoDB': - $databaseType = $this->getDatabaseType(); - $version = $this->getDatabaseVersion() ?? ''; - - switch ($databaseType) { - case self::TYPE_MARIADB: - if (version_compare($version, '10.2.2') >= 0) { - return 3072; // InnoDB, MariaDB 10.2.2+ - } - - break; - - case self::TYPE_MYSQL: - return 3072; - } - - return 767; // InnoDB - } - - return 1000; // MyISAM + ->create($platform, $this->getPDO()) + ->create($params); } /** * Get a database type (MySQL, MariaDB, PostgreSQL). */ - public function getDatabaseType(): string + public function getType(): string { - $version = $this->getFullDatabaseVersion() ?? ''; - - if (preg_match('/mariadb/i', $version)) { - return self::TYPE_MARIADB; - } - - if (preg_match('/postgresql/i', $version)) { - return self::TYPE_POSTGRESQL; - } - - return self::TYPE_MYSQL; - } - - private function getFullDatabaseVersion(): ?string - { - $connection = $this->getPdoConnection(); - - $sth = $connection->prepare("select version()"); - - $sth->execute(); - - /** @var string|null|false $result */ - $result = $sth->fetchColumn(); - - if ($result === false || $result === null) { - return null; - } - - return $result; + return $this->createDetailsProvider()->getType(); } /** * Get a database version. - * - * @todo Add PostgreSQL support. */ - public function getDatabaseVersion(): ?string + public function getVersion(): string { - $fullVersion = $this->getFullDatabaseVersion() ?? ''; - - if (preg_match('/[0-9]+\.[0-9]+\.[0-9]+/', $fullVersion, $match)) { - return $match[0]; - } - - return null; + return $this->createDetailsProvider()->getVersion(); } /** - * Get a table or default engine. If a table name is null, get a value for all database tables. + * Get a database parameter. */ - private function getTableEngine(?string $tableName = null): ?string + public function getParam(string $name): ?string { - $databaseType = $this->getDatabaseType(); - - if (!in_array($databaseType, [self::TYPE_MYSQL, self::TYPE_MARIADB])) { - return null; - } - - $query = $tableName ? - "SHOW TABLE STATUS WHERE Engine = 'MyISAM' AND Name = :tableName" : - "SHOW TABLE STATUS WHERE Engine = 'MyISAM'"; - - $vars = []; - - if ($tableName) { - $vars[':tableName'] = $tableName; - } - - $sth = $this->getPdoConnection()->prepare($query); - $sth->execute($vars); - - $result = $sth->fetchColumn(); - - if (!empty($result)) { - return 'MyISAM'; - } - - return 'InnoDB'; + return $this->createDetailsProvider()->getParam($name); } - public function getDatabaseParam(string $name): ?string + /** + * Get a database server version string. + */ + public function getServerVersion(): string { - $databaseType = $this->getDatabaseType(); - - if ($databaseType === self::TYPE_POSTGRESQL) { - // @todo Implement. - return null; - } - - $sql = "SHOW VARIABLES LIKE :param";; - - $sth = $this->getPdoConnection()->prepare($sql); - $sth->execute([':param' => $name]); - - $row = $sth->fetch(PDO::FETCH_NUM); - - $index = 1; - - $value = $row[$index] ?: null; - - if ($value === null) { - return null; - } - - return (string) $value; + return $this->createDetailsProvider()->getServerVersion(); } - public function getDatabaseServerVersion(): string + private function createDetailsProvider(): DetailsProvider { - $databaseType = $this->getDatabaseType(); + $platform = $this->configDataProvider->getPlatform(); - $param = $databaseType === self::TYPE_POSTGRESQL ? - 'server_version' : - 'version'; - - return (string) $this->getDatabaseParam($param); + return $this->detailsProviderFactory->create($platform, $this->getPDO()); } } diff --git a/application/Espo/Core/Utils/Database/Orm/Converter.php b/application/Espo/Core/Utils/Database/Orm/Converter.php index 6125ad6b63..76bfe8ee21 100644 --- a/application/Espo/Core/Utils/Database/Orm/Converter.php +++ b/application/Espo/Core/Utils/Database/Orm/Converter.php @@ -29,10 +29,13 @@ namespace Espo\Core\Utils\Database\Orm; +use Doctrine\DBAL\Types\Types; +use Espo\Core\Utils\Database\ConfigDataProvider; use Espo\Core\Utils\Util; +use Espo\ORM\Defs\AttributeDefs; use Espo\ORM\Defs\IndexDefs; +use Espo\ORM\Defs\RelationDefs; use Espo\ORM\Entity; -use Espo\Core\Utils\Database\Schema\Utils as SchemaUtils; use Espo\Core\Utils\Metadata; use Espo\Core\Utils\Config; use Espo\Core\Utils\Metadata\Helper as MetadataHelper; @@ -42,7 +45,10 @@ class Converter /** @var ?array */ private $entityDefs = null; - private string $defaultFieldType = 'varchar'; + private string $defaultAttributeType = Entity::VARCHAR; + + private const INDEX_TYPE_UNIQUE = 'unique'; + private const INDEX_TYPE_INDEX = 'index'; /** @var array */ private $defaultLength = [ @@ -50,7 +56,7 @@ class Converter 'int' => 11, ]; - /** @var array */ + /** @var array */ private $defaultValue = [ 'bool' => false, ]; @@ -60,7 +66,7 @@ class Converter * * @var array */ - private $fieldAccordances = [ + private $paramMap = [ 'type' => 'type', 'dbType' => 'dbType', 'maxLength' => 'len', @@ -88,7 +94,7 @@ class Converter /** @var array */ private $idParams = [ 'dbType' => 'varchar', - 'len' => 24, + 'len' => 24, // @todo Make configurable. ]; /** @@ -101,12 +107,20 @@ class Converter 'additionalTables', ]; + private IndexHelper $indexHelper; + public function __construct( private Metadata $metadata, private Config $config, - private RelationManager $relationManager, - private MetadataHelper $metadataHelper - ) {} + private RelationConverter $relationConverter, + private MetadataHelper $metadataHelper, + ConfigDataProvider $configDataProvider, + IndexHelperFactory $indexHelperFactory + ) { + $platform = $configDataProvider->getPlatform(); + + $this->indexHelper = $indexHelperFactory->create($platform); + } /** * @param bool $reload @@ -137,29 +151,37 @@ class Converter $ormMetadata[$entityType]['skipRebuild'] = true; } - /** @var array> $ormMetadata */ - $ormMetadata = Util::merge($ormMetadata, $this->convertEntity($entityType, $entityMetadata)); + /** @var array> $ormMetadata */ + $ormMetadata = Util::merge( + $ormMetadata, + $this->convertEntity($entityType, $entityMetadata) + ); } $ormMetadata = $this->afterFieldsProcess($ormMetadata); - foreach ($ormMetadata as $entityType => $entityOrmMetadata) { - /** @var array> $ormMetadata */ + foreach ($ormMetadata as $entityOrmMetadata) { + /** @var array> $ormMetadata */ $ormMetadata = Util::merge( $ormMetadata, - $this->createRelationsEntityDefs($entityType, $entityOrmMetadata) + $this->createEntityTypesFromRelations($entityOrmMetadata) ); - /** @var array> $ormMetadata */ + /** @var array> $ormMetadata */ $ormMetadata = Util::merge( $ormMetadata, - $this->createAdditionalEntityTypes($entityType, $entityOrmMetadata) + $this->createAdditionalEntityTypes($entityOrmMetadata) ); } return $this->afterProcess($ormMetadata); } + private function composeIndexKey(IndexDefs $defs, string $entityType): string + { + return $this->indexHelper->composeKey($defs, $entityType); + } + /** * @param array $entityMetadata * @return array @@ -268,7 +290,7 @@ class Converter $constName = strtoupper(Util::toUnderScore($attributeParams['type'])); if (!defined('Espo\\ORM\\Entity::' . $constName)) { - $attributeParams['type'] = $this->defaultFieldType; + $attributeParams['type'] = $this->defaultAttributeType; } break; @@ -473,36 +495,37 @@ class Converter } } - // @todo move to separate file - $scopeDefs = $this->metadata->get('scopes.'.$entityType); + // @todo Refactor. + /** @var array $scopeDefs */ + $scopeDefs = $this->metadata->get(['scopes', $entityType]) ?? []; - if (isset($scopeDefs['stream']) && $scopeDefs['stream']) { + if ($scopeDefs['stream'] ?? false) { if (!isset($entityMetadata['fields']['isFollowed'])) { $ormMetadata[$entityType]['fields']['isFollowed'] = [ - 'type' => 'varchar', + 'type' => Entity::VARCHAR, 'notStorable' => true, 'notExportable' => true, ]; $ormMetadata[$entityType]['fields']['followersIds'] = [ - 'type' => 'jsonArray', + 'type' => Entity::JSON_ARRAY, 'notStorable' => true, 'notExportable' => true, ]; $ormMetadata[$entityType]['fields']['followersNames'] = [ - 'type' => 'jsonObject', + 'type' => Entity::JSON_OBJECT, 'notStorable' => true, 'notExportable' => true, ]; } } - // @todo move to separate file + // @todo Refactor. if ($this->metadata->get(['entityDefs', $entityType, 'optimisticConcurrencyControl'])) { $ormMetadata[$entityType]['fields']['versionNumber'] = [ 'type' => Entity::INT, - 'dbType' => 'bigint', + 'dbType' => Types::BIGINT, 'notExportable' => true, ]; } @@ -511,9 +534,9 @@ class Converter } /** - * @param array $fieldParams - * @param ?array $fieldTypeMetadata - * @return array|false + * @param array $fieldParams + * @param ?array $fieldTypeMetadata + * @return array|false */ private function convertField( string $entityType, @@ -528,7 +551,7 @@ class Converter $this->prepareFieldParamsBeforeConvert($fieldParams); if (isset($fieldTypeMetadata['fieldDefs'])) { - /** @var array $fieldParams */ + /** @var array $fieldParams */ $fieldParams = Util::merge($fieldParams, $fieldTypeMetadata['fieldDefs']); } @@ -564,7 +587,7 @@ class Converter } /** - * @param array $fieldParams + * @param array $fieldParams */ private function prepareFieldParamsBeforeConvert(array &$fieldParams): void { @@ -589,16 +612,16 @@ class Converter } $relationships = []; - foreach ($entityMetadata['links'] as $linkName => $linkParams) { + foreach ($entityMetadata['links'] as $linkName => $linkParams) { if (isset($linkParams['skipOrmDefs']) && $linkParams['skipOrmDefs'] === true) { continue; } - $convertedLink = $this->relationManager->convert($linkName, $linkParams, $entityType, $ormMetadata); + $convertedLink = $this->relationConverter->process($linkName, $linkParams, $entityType, $ormMetadata); - if (isset($convertedLink)) { - /** @var array $relationships */ + if ($convertedLink) { + /** @var array $relationships */ $relationships = Util::merge($convertedLink, $relationships); } } @@ -614,7 +637,7 @@ class Converter { $values = []; - foreach ($this->fieldAccordances as $espoType => $ormType) { + foreach ($this->paramMap as $espoType => $ormType) { if (!array_key_exists($espoType, $attributeParams)) { continue; } @@ -712,38 +735,85 @@ class Converter /** * @param array $ormMetadata */ - private function applyIndexes(&$ormMetadata, string $entityType): void + private function applyIndexes(array &$ormMetadata, string $entityType): void { - if (isset($ormMetadata[$entityType]['fields'])) { - $indexList = SchemaUtils::getEntityIndexListByFieldsDefs($ormMetadata[$entityType]['fields']); + $defs = &$ormMetadata[$entityType]; + + $defs['indexes'] ??= []; + + if (isset($defs['fields'])) { + $indexList = self::getEntityIndexListFromAttributes($defs['fields']); foreach ($indexList as $indexName => $indexParams) { - if (!isset($ormMetadata[$entityType]['indexes'][$indexName])) { - $ormMetadata[$entityType]['indexes'][$indexName] = $indexParams; + if (!isset($defs['indexes'][$indexName])) { + $defs['indexes'][$indexName] = $indexParams; } } } - if (isset($ormMetadata[$entityType]['indexes'])) { - foreach ($ormMetadata[$entityType]['indexes'] as $indexName => &$indexData) { - $indexDefs = IndexDefs::fromRaw($indexData, $indexName); + foreach ($defs['indexes'] as $indexName => &$indexData) { + $indexDefs = IndexDefs::fromRaw($indexData, $indexName); - if (!$indexDefs->getKey()) { - $indexData['key'] = SchemaUtils::generateIndexName($indexDefs, $entityType); - } + if (!$indexDefs->getKey()) { + $indexData['key'] = $this->composeIndexKey($indexDefs, $entityType); } } - if (isset($ormMetadata[$entityType]['relations'])) { - foreach ($ormMetadata[$entityType]['relations'] as &$relationData) { - if (isset($relationData['indexes'])) { - foreach ($relationData['indexes'] as $indexName => &$indexData) { - $indexDefs = IndexDefs::fromRaw($indexData, $indexName); + if (isset($defs['relations'])) { + foreach ($defs['relations'] as &$relationData) { + $type = $relationData['type'] ?? null; - $relationName = $relationData['relationName'] ?? ''; + if ($type !== Entity::MANY_MANY) { + continue; + } - $indexData['key'] = SchemaUtils::generateIndexName($indexDefs, $relationName); + $relationName = $relationData['relationName'] ?? ''; + + $relationData['indexes'] ??= []; + + $uniqueColumnList = []; + + foreach (($relationData['midKeys'] ?? []) as $midKey) { + $indexName = $midKey; + + $indexDefs = IndexDefs::fromRaw(['columns' => [$midKey]], $indexName); + + $relationData['indexes'][$indexName] = [ + 'columns' => $indexDefs->getColumnList(), + 'key' => $this->composeIndexKey($indexDefs, ucfirst($relationName)), + ]; + + $uniqueColumnList[] = $midKey; + } + + foreach ($relationData['indexes'] as $indexName => &$indexData) { + if (!empty($indexData['key'])) { + continue; } + + $indexDefs = IndexDefs::fromRaw($indexData, $indexName); + + $indexData['key'] = $this->composeIndexKey($indexDefs, ucfirst($relationName)); + } + + foreach (($relationData['conditions'] ?? []) as $column => $fieldParams) { + $uniqueColumnList[] = $column; + } + + if ($uniqueColumnList !== []) { + $indexName = implode('_', $uniqueColumnList); + + $indexDefs = IndexDefs + ::fromRaw([ + 'columns' => $uniqueColumnList, + 'type' => self::INDEX_TYPE_UNIQUE, + ], $indexName); + + $relationData['indexes'][$indexName] = [ + 'type' => self::INDEX_TYPE_UNIQUE, + 'columns' => $indexDefs->getColumnList(), + 'key' => $this->composeIndexKey($indexDefs, ucfirst($relationName)), + ]; } } } @@ -753,54 +823,90 @@ class Converter * @param array $defs * @return array */ - private function createAdditionalEntityTypes(string $entityType, array $defs): array + private function createAdditionalEntityTypes(array $defs): array { - if (empty($defs['additionalTables'])) { + /** @var array> $additionalDefs */ + $additionalDefs = $defs['additionalTables'] ?? []; + + if ($additionalDefs === []) { return []; } - return $defs['additionalTables']; + /** @var string[] $entityTypeList */ + $entityTypeList = array_keys($additionalDefs); + + foreach ($entityTypeList as $itemEntityType) { + $this->applyIndexes($additionalDefs, $itemEntityType); + } + + return $additionalDefs; } /** * @param array $defs * @return array */ - private function createRelationsEntityDefs(string $entityType, array $defs): array + private function createEntityTypesFromRelations(array $defs): array { $result = []; - foreach ($defs['relations'] as $relationParams) { - if ($relationParams['type'] !== 'manyMany') { + foreach ($defs['relations'] as $name => $relationParams) { + $relationDefs = RelationDefs::fromRaw($relationParams, $name); + + if ($relationDefs->getType() !== Entity::MANY_MANY) { continue; } - $relationEntityType = ucfirst($relationParams['relationName']); + $relationEntityType = ucfirst($relationDefs->getRelationshipName()); $itemDefs = [ 'skipRebuild' => true, 'fields' => [ 'id' => [ - 'type' => 'id', + 'type' => Entity::ID, 'autoincrement' => true, - 'dbType' => 'bigint', // ignored because of `skipRebuild` + 'dbType' => Types::BIGINT, // ignored because of `skipRebuild` ], 'deleted' => [ - 'type' => 'bool' + 'type' => Entity::BOOL, ], ], ]; - foreach ($relationParams['midKeys'] ?? [] as $key) { + $key1 = $relationDefs->getMidKey(); + $key2 = $relationDefs->getForeignMidKey(); + + $midKeys = [$key1, $key2]; + + foreach ($midKeys as $key) { $itemDefs['fields'][$key] = [ - 'type' => 'foreignId', + 'type' => Entity::FOREIGN_ID, ]; } - foreach ($relationParams['additionalColumns'] ?? [] as $columnName => $columnItem) { - $itemDefs['fields'][$columnName] = [ - 'type' => $columnItem['type'] ?? 'varchar', + foreach ($relationDefs->getParam('additionalColumns') ?? [] as $columnName => $columnItem) { + $columnItem['type'] ??= Entity::VARCHAR; + + $attributeDefs = AttributeDefs::fromRaw($columnItem, $columnName); + + $columnDefs = [ + 'type' => $attributeDefs->getType(), ]; + + if ($attributeDefs->getLength()) { + $columnDefs['len'] = $attributeDefs->getLength(); + } + + if ($attributeDefs->getParam('default') !== null) { + $columnDefs['default'] = $attributeDefs->getParam('default'); + } + + $itemDefs['fields'][$columnName] = $columnDefs; + } + + foreach ($relationDefs->getIndexList() as $indexDefs) { + $itemDefs['indexes'] ??= []; + $itemDefs['indexes'][] = self::convertIndexDefsToRaw($indexDefs); } $result[$relationEntityType] = $itemDefs; @@ -808,4 +914,93 @@ class Converter return $result; } + + /** + * @return array + */ + private static function convertIndexDefsToRaw(IndexDefs $indexDefs): array + { + return [ + 'type' => $indexDefs->isUnique() ? self::INDEX_TYPE_UNIQUE : self::INDEX_TYPE_INDEX, + 'columns' => $indexDefs->getColumnList(), + 'flags' => $indexDefs->getFlagList(), + 'key' => $indexDefs->getKey(), + ]; + } + + /** + * @param array $attributesMetadata + * @return array + */ + private static function getEntityIndexListFromAttributes(array $attributesMetadata): array + { + $indexList = []; + + foreach ($attributesMetadata as $attributeName => $rawParams) { + $attributeDefs = AttributeDefs::fromRaw($rawParams, $attributeName); + + if ($attributeDefs->isNotStorable()) { + continue; + } + + $indexType = self::getIndexTypeByAttributeDefs($attributeDefs); + $indexName = self::getIndexNameByAttributeDefs($attributeDefs); + + if (!$indexType || !$indexName) { + continue; + } + + $keyValue = $attributeDefs->getParam($indexType); + + if ($keyValue === true) { + $indexList[$indexName]['type'] = $indexType; + $indexList[$indexName]['columns'] = [$attributeName]; + } + else if (is_string($keyValue)) { + $indexList[$indexName]['type'] = $indexType; + $indexList[$indexName]['columns'][] = $attributeName; + } + } + + /** @var array */ + return $indexList; + } + + + private static function getIndexTypeByAttributeDefs(AttributeDefs $attributeDefs): ?string + { + if ( + $attributeDefs->getType() !== Entity::ID && + $attributeDefs->getParam(self::INDEX_TYPE_UNIQUE) + ) { + return self::INDEX_TYPE_UNIQUE; + } + + if ($attributeDefs->getParam(self::INDEX_TYPE_INDEX)) { + return self::INDEX_TYPE_INDEX; + } + + return null; + } + + private static function getIndexNameByAttributeDefs(AttributeDefs $attributeDefs): ?string + { + $indexType = self::getIndexTypeByAttributeDefs($attributeDefs); + + if (!$indexType) { + return null; + } + + $keyValue = $attributeDefs->getParam($indexType); + + if ($keyValue === true) { + return $attributeDefs->getName(); + } + + if (is_string($keyValue)) { + return $keyValue; + } + + return null; + } } diff --git a/application/Espo/Core/Utils/Database/Schema/rebuildActions/AddSystemUser.php b/application/Espo/Core/Utils/Database/Orm/IndexHelper.php similarity index 67% rename from application/Espo/Core/Utils/Database/Schema/rebuildActions/AddSystemUser.php rename to application/Espo/Core/Utils/Database/Orm/IndexHelper.php index 0e70d4393f..435412592f 100644 --- a/application/Espo/Core/Utils/Database/Schema/rebuildActions/AddSystemUser.php +++ b/application/Espo/Core/Utils/Database/Orm/IndexHelper.php @@ -27,31 +27,14 @@ * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. ************************************************************************/ -namespace Espo\Core\Utils\Database\Schema\rebuildActions; +namespace Espo\Core\Utils\Database\Orm; -use Espo\Core\Utils\Database\Schema\BaseRebuildActions as Base; +use Espo\ORM\Defs\IndexDefs; -class AddSystemUser extends Base +interface IndexHelper { /** - * @return void + * Compose an index DB name. Depending on database, the name can be unique, limited by a max length. */ - public function afterRebuild() - { - $userId = $this->getConfig()->get('systemUserAttributes.id'); - - $user = $this->getEntityManager()->getEntity('User', $userId); - - if ($user) { - return; - } - - $systemUserAttributes = $this->getConfig()->get('systemUserAttributes'); - - $user = $this->getEntityManager()->getNewEntity('User'); - - $user->set($systemUserAttributes); - - $this->getEntityManager()->saveEntity($user); - } + public function composeKey(IndexDefs $defs, string $entityType): string; } diff --git a/application/Espo/Core/Utils/Database/Orm/IndexHelperFactory.php b/application/Espo/Core/Utils/Database/Orm/IndexHelperFactory.php new file mode 100644 index 0000000000..89e74457f0 --- /dev/null +++ b/application/Espo/Core/Utils/Database/Orm/IndexHelperFactory.php @@ -0,0 +1,55 @@ + $className */ + $className = $this->metadata + ->get(['app', 'database', 'platforms', $platform, 'indexHelperClassName']); + + if (!$className) { + throw new RuntimeException("No Index Helper for {$platform}"); + } + + return $this->injectableFactory->create($className); + } +} diff --git a/application/Espo/Core/Utils/Database/Orm/IndexHelpers/MysqlIndexHelper.php b/application/Espo/Core/Utils/Database/Orm/IndexHelpers/MysqlIndexHelper.php new file mode 100644 index 0000000000..99b9fd312a --- /dev/null +++ b/application/Espo/Core/Utils/Database/Orm/IndexHelpers/MysqlIndexHelper.php @@ -0,0 +1,51 @@ +getName(); + $prefix = $defs->isUnique() ? 'UNIQ' : 'IDX'; + + $parts = [$prefix, strtoupper(Util::toUnderScore($name))]; + + $key = implode('_', $parts); + + return substr($key, 0, self::MAX_LENGTH); + } +} diff --git a/application/Espo/Core/Utils/Database/Orm/RelationManager.php b/application/Espo/Core/Utils/Database/Orm/RelationConverter.php similarity index 81% rename from application/Espo/Core/Utils/Database/Orm/RelationManager.php rename to application/Espo/Core/Utils/Database/Orm/RelationConverter.php index fe6ed68052..726e4f78b7 100644 --- a/application/Espo/Core/Utils/Database/Orm/RelationManager.php +++ b/application/Espo/Core/Utils/Database/Orm/RelationConverter.php @@ -33,27 +33,17 @@ use Espo\Core\Utils\Util; use Espo\Core\Utils\Metadata; use Espo\Core\Utils\Config; -class RelationManager +class RelationConverter { public function __construct( private Metadata $metadata, private Config $config ) {} - /** - * @param string $entityName - * @param array $linkParams - * @return string - */ - public function getLinkEntityName($entityName, $linkParams) - { - return $linkParams['entity'] ?? $entityName; - } - /** * @param string $relationName */ - public function relationExists($relationName): bool + private function relationExists($relationName): bool { if ($this->getRelationClass($relationName) !== false) { return true; @@ -85,12 +75,11 @@ class RelationManager } /** - * Get foreign link. + * Get a foreign link. * - * @param array $parentLinkParams - * @param array $currentEntityDefs - * - * @return array{name:string,params:array}|false + * @param array $parentLinkParams + * @param array $currentEntityDefs + * @return array{name: string, params: array}|false */ private function getForeignLink($parentLinkParams, $currentEntityDefs) { @@ -107,16 +96,16 @@ class RelationManager /** * @param string $linkName * @param array $linkParams - * @param string $entityName + * @param string $entityType * @param array $ormMetadata * @return ?array */ - public function convert($linkName, $linkParams, $entityName, $ormMetadata) + public function process(string $linkName, array $linkParams, string $entityType, array $ormMetadata): ?array { $entityDefs = $this->metadata->get('entityDefs'); - $foreignEntityName = $this->getLinkEntityName($entityName, $linkParams); - $foreignLink = $this->getForeignLink($linkParams, $entityDefs[$foreignEntityName]); + $foreignEntityType = $linkParams['entity'] ?? $entityType; + $foreignLink = $this->getForeignLink($linkParams, $entityDefs[$foreignEntityType]); $currentType = $linkParams['type']; @@ -132,7 +121,6 @@ class RelationManager $relType /*hasManyHasMany*/ : $currentType /*hasMany*/; - // relationDefs defined in separate file if (isset($linkParams['relationName'])) { $className = $this->getRelationClass($linkParams['relationName']); @@ -150,9 +138,8 @@ class RelationManager $helperClass = new $className($this->metadata, $ormMetadata, $entityDefs, $this->config); - return $helperClass->process($linkName, $entityName, $foreignLinkName, $foreignEntityName); + return $helperClass->process($linkName, $entityType, $foreignLinkName, $foreignEntityType); } - //END: relationDefs defined in separate file return null; } diff --git a/application/Espo/Core/Utils/Database/Orm/Relations/Base.php b/application/Espo/Core/Utils/Database/Orm/Relations/Base.php index 879f944535..32789657e3 100644 --- a/application/Espo/Core/Utils/Database/Orm/Relations/Base.php +++ b/application/Espo/Core/Utils/Database/Orm/Relations/Base.php @@ -30,16 +30,19 @@ namespace Espo\Core\Utils\Database\Orm\Relations; use Espo\Core\Utils\Util; +use Espo\ORM\Entity; class Base extends \Espo\Core\Utils\Database\Orm\Base { + private const DEFAULT_VARCHAR_LENGTH = 255; + /** - * @var array + * @var array */ private $params; /** - * @var array + * @var array */ private $foreignParams; @@ -62,11 +65,11 @@ class Base extends \Espo\Core\Utils\Database\Orm\Base 'additionalColumns', 'midKeys', 'noJoin', - 'indexes' + 'indexes', ]; /** - * @return array + * @return array */ protected function getParams() { @@ -74,7 +77,7 @@ class Base extends \Espo\Core\Utils\Database\Orm\Base } /** - * @return array + * @return array */ protected function getForeignParams() { @@ -82,7 +85,7 @@ class Base extends \Espo\Core\Utils\Database\Orm\Base } /** - * @param array $params + * @param array $params * @return void */ protected function setParams(array $params) @@ -91,7 +94,7 @@ class Base extends \Espo\Core\Utils\Database\Orm\Base } /** - * @param array $foreignParams + * @param array $foreignParams * @return void */ protected function setForeignParams(array $foreignParams) @@ -134,7 +137,7 @@ class Base extends \Espo\Core\Utils\Database\Orm\Base } /** - * @return array + * @return array */ protected function getForeignLinkParams() { @@ -148,26 +151,30 @@ class Base extends \Espo\Core\Utils\Database\Orm\Base /** * * @param string $linkName - * @param string $entityName + * @param string $entityType * @param ?string $foreignLinkName * @param ?string $foreignEntityName - * @return array + * @return array */ - public function process($linkName, $entityName, $foreignLinkName, $foreignEntityName) + public function process($linkName, $entityType, $foreignLinkName, $foreignEntityName) { $inputs = [ 'itemName' => $linkName, - 'entityName' => $entityName, + 'entityName' => $entityType, 'foreignLinkName' => $foreignLinkName, 'foreignEntityName' => $foreignEntityName, ]; $this->setMethods($inputs); - $convertedDefs = $this->load($linkName, $entityName); + $convertedDefs = $this->load($linkName, $entityType); $convertedDefs = $this->mergeAllowedParams($convertedDefs); + if (isset($convertedDefs[$entityType]['relations'][$linkName])) { + $this->correct($convertedDefs[$entityType]['relations'][$linkName]); + } + $inputs = $this->setArrayValue(null, $inputs); $this->setMethods($inputs); @@ -176,8 +183,34 @@ class Base extends \Espo\Core\Utils\Database\Orm\Base } /** - * @param array $loads - * @return array' + * @param array $relationDefs + */ + private function correct(array &$relationDefs): void + { + if (!isset($relationDefs['additionalColumns'])) { + return; + } + + /** @var array> $additionalColumns */ + $additionalColumns = &$relationDefs['additionalColumns']; + + foreach ($additionalColumns as &$columnDefs) { + $columnDefs['type'] ??= Entity::VARCHAR; + + if ( + $columnDefs['type'] === Entity::VARCHAR && + !isset($columnDefs['len']) + ) { + $columnDefs['len'] = self::DEFAULT_VARCHAR_LENGTH; + } + } + + $relationDefs['additionalColumns'] = $additionalColumns; + } + + /** + * @param array $loads + * @return array */ private function mergeAllowedParams($loads) { @@ -193,7 +226,7 @@ class Base extends \Espo\Core\Utils\Database\Orm\Base if (isset($additionalParam)) { $linkParams[$name] = $additionalParam; - if (isset($linkParams[$name]) && is_array($linkParams[$name])) { + if (is_array($linkParams[$name])) { $linkParams[$name] = Util::merge($linkParams[$name], $additionalParam); } } @@ -205,17 +238,15 @@ class Base extends \Espo\Core\Utils\Database\Orm\Base /** * @param string $allowedItemName - * @return ?array + * @return ?array */ private function getAllowedAdditionalParam($allowedItemName) { $linkParams = $this->getLinkParams(); $foreignLinkParams = $this->getForeignLinkParams(); - $itemLinkParams = isset($linkParams[$allowedItemName]) ? $linkParams[$allowedItemName] : null; - $itemForeignLinkParams = isset($foreignLinkParams[$allowedItemName]) ? - $foreignLinkParams[$allowedItemName] : - null; + $itemLinkParams = $linkParams[$allowedItemName] ?? null; + $itemForeignLinkParams = $foreignLinkParams[$allowedItemName] ?? null; $additionalParam = null; diff --git a/application/Espo/Core/Utils/Database/Orm/Relations/ManyMany.php b/application/Espo/Core/Utils/Database/Orm/Relations/ManyMany.php index 380bcb8cd3..83abb6f65f 100644 --- a/application/Espo/Core/Utils/Database/Orm/Relations/ManyMany.php +++ b/application/Espo/Core/Utils/Database/Orm/Relations/ManyMany.php @@ -38,7 +38,7 @@ class ManyMany extends Base /** * @param string $linkName * @param string $entityType - * @return array + * @return array */ protected function load($linkName, $entityType) { @@ -132,7 +132,7 @@ class ManyMany extends Base /** * @param string $entity1 * @param string $entity2 - * @return array{string,string} + * @return array{string, string} */ protected function getSortEntities($entity1, $entity2) { @@ -146,4 +146,4 @@ class ManyMany extends Base return $entities; } -} \ No newline at end of file +} diff --git a/application/Espo/Core/Utils/Database/Schema/BaseRebuildActions.php b/application/Espo/Core/Utils/Database/Schema/BaseRebuildActions.php deleted file mode 100644 index 2563b5bc1c..0000000000 --- a/application/Espo/Core/Utils/Database/Schema/BaseRebuildActions.php +++ /dev/null @@ -1,97 +0,0 @@ -metadata = $metadata; - $this->config = $config; - $this->entityManager = $entityManager; - $this->log = $log; - } - - protected function getEntityManager(): EntityManager - { - return $this->entityManager; - } - - protected function getConfig(): Config - { - return $this->config; - } - - protected function getMetadata(): Metadata - { - return $this->metadata; - } - - public function setCurrentSchema(DbalSchema $currentSchema): void - { - $this->currentSchema = $currentSchema; - } - - public function setMetadataSchema(DbalSchema $metadataSchema): void - { - $this->metadataSchema = $metadataSchema; - } - - protected function getCurrentSchema(): ?DbalSchema - { - return $this->currentSchema; - } - - protected function getMetadataSchema(): ?DbalSchema - { - return $this->metadataSchema; - } -} diff --git a/application/Espo/Core/Utils/Database/Schema/Builder.php b/application/Espo/Core/Utils/Database/Schema/Builder.php new file mode 100644 index 0000000000..6a315018d0 --- /dev/null +++ b/application/Espo/Core/Utils/Database/Schema/Builder.php @@ -0,0 +1,519 @@ +typeList = array_keys(DbalType::getTypesMap()); + + $platform = $configDataProvider->getPlatform(); + + $this->columnPreparator = $columnPreparatorFactory->create($platform); + } + + /** + * Build a schema representation for an ORM metadata. + * + * @param array $ormMeta Raw ORM metadata. + * @param ?string[] $entityTypeList Specific entity types. + * @throws SchemaException + */ + public function build(array $ormMeta, ?array $entityTypeList = null): DbalSchema + { + $this->log->debug('Schema\Builder - Start'); + + $ormMeta = $this->amendMetadata($ormMeta, $entityTypeList); + $tables = []; + + $schema = new DbalSchema(); + + foreach ($ormMeta as $entityType => $entityParams) { + $entityDefs = EntityDefs::fromRaw($entityParams, $entityType); + + $this->buildEntity($entityDefs, $schema, $tables); + } + + foreach ($ormMeta as $entityType => $entityParams) { + foreach (($entityParams['relations'] ?? []) as $relationName => $relationParams) { + $relationDefs = RelationDefs::fromRaw($relationParams, $relationName); + + if ($relationDefs->getType() !== Entity::MANY_MANY) { + continue; + } + + $this->buildManyMany($entityType, $relationDefs, $schema, $tables); + } + } + + $this->log->debug('Schema\Builder - End'); + + return $schema; + } + + /** + * @param array $tables + * @throws SchemaException + */ + private function buildEntity(EntityDefs $entityDefs, DbalSchema $schema, array &$tables): void + { + if ($entityDefs->getParam('skipRebuild')) { + return; + } + + $entityType = $entityDefs->getName(); + + $this->log->debug("Schema\Builder: Entity {$entityType}"); + + $tableName = Util::toUnderScore($entityType); + + if ($schema->hasTable($tableName)) { + $tables[$entityType] ??= $schema->getTable($tableName); + + $this->log->debug('Schema\Builder: Table [' . $tableName . '] exists.'); + + return; + } + + $table = $schema->createTable($tableName); + + $tables[$entityType] = $table; + + /** @var array $tableParams */ + $tableParams = $entityDefs->getParam('params') ?? []; + + foreach ($tableParams as $paramName => $paramValue) { + $table->addOption($paramName, $paramValue); + } + + $primaryColumns = []; + + foreach ($entityDefs->getAttributeList() as $attributeDefs) { + if ( + $attributeDefs->isNotStorable() || + $attributeDefs->getType() === Entity::FOREIGN + ) { + continue; + } + + $column = $this->columnPreparator->prepare($attributeDefs); + + if ($attributeDefs->getType() === Entity::ID) { + $primaryColumns[] = $column->getName(); + } + + if (!in_array($column->getType(), $this->typeList)) { + $this->log->debug( + 'Schema\Builder: Column type [' . $column->getType() . '] not supported, ' . + $entityType . ':' . $attributeDefs->getName() + ); + + continue; + } + + if ($table->hasColumn($column->getName())) { + continue; + } + + $this->addColumn($table, $column); + } + + $table->setPrimaryKey($primaryColumns); + + $this->addIndexes($table, $entityDefs->getIndexList()); + } + + /** + * @param array $ormMeta + * @param ?string[] $entityTypeList + * @return array + */ + private function amendMetadata(array $ormMeta, ?array $entityTypeList): array + { + /** @var array $ormMeta */ + $ormMeta = Util::merge( + $ormMeta, + $this->getCustomTables() + ); + + if (isset($ormMeta['unsetIgnore'])) { + $protectedOrmMeta = []; + + foreach ($ormMeta['unsetIgnore'] as $protectedKey) { + $protectedOrmMeta = Util::merge( + $protectedOrmMeta, + Util::fillArrayKeys($protectedKey, Util::getValueByKey($ormMeta, $protectedKey)) + ); + } + + unset($ormMeta['unsetIgnore']); + } + + // Unset some keys. + if (isset($ormMeta['unset'])) { + /** @var array $ormMeta */ + $ormMeta = Util::unsetInArray($ormMeta, $ormMeta['unset']); + + unset($ormMeta['unset']); + } + + if (isset($protectedOrmMeta)) { + /** @var array $ormMeta */ + $ormMeta = Util::merge($ormMeta, $protectedOrmMeta); + } + + if (isset($entityTypeList)) { + $dependentEntityTypeList = $this->getDependentEntityTypeList($entityTypeList, $ormMeta); + + $this->log->debug( + 'Schema\Builder: Rebuild for entity types: [' . + implode(', ', $entityTypeList) . '] with dependent entity types: [' . + implode(', ', $dependentEntityTypeList) . ']' + ); + + $ormMeta = array_intersect_key($ormMeta, array_flip($dependentEntityTypeList)); + } + + return $ormMeta; + } + + /** + * @throws SchemaException + */ + private function addColumn(Table $table, Column $column): void + { + $table->addColumn( + $column->getName(), + $column->getType(), + self::convertColumn($column) + ); + } + + /** + * Prepare a relation table for the manyMany relation. + * + * @param string $entityType + * @param array $tables + * @throws SchemaException + */ + private function buildManyMany( + string $entityType, + RelationDefs $relationDefs, + DbalSchema $schema, + array &$tables + ): void { + + $relationshipName = $relationDefs->getRelationshipName(); + + if (isset($tables[$relationshipName])) { + return; + } + + $tableName = Util::toUnderScore($relationshipName); + + $this->log->debug("Schema\Builder: ManyMany for {$entityType}.{$relationDefs->getName()}"); + + if ($schema->hasTable($tableName)) { + $this->log->debug('Schema\Builder: Table [' . $tableName . '] exists.'); + + $tables[$relationshipName] ??= $schema->getTable($tableName); + + return; + } + + $table = $schema->createTable($tableName); + + $idColumn = $this->columnPreparator->prepare( + AttributeDefs::fromRaw([ + 'dbType' => Types::BIGINT, + 'type' => Entity::ID, + 'len' => 20, + 'autoincrement' => true, + ], self::ATTR_ID) + ); + + $this->addColumn($table, $idColumn); + + if (!$relationDefs->hasMidKey() || !$relationDefs->getForeignMidKey()) { + $this->log->error('Schema\Builder: Relationship midKeys are empty.', [ + 'entityType' => $entityType, + 'relationName' => $relationDefs->getName(), + ]); + + return; + } + + $midKeys = [ + $relationDefs->getMidKey(), + $relationDefs->getForeignMidKey(), + ]; + + foreach ($midKeys as $midKey) { + $column = $this->columnPreparator->prepare( + AttributeDefs::fromRaw([ + 'dbType' => Entity::VARCHAR, + 'type' => Entity::FOREIGN_ID, + 'len' => self::ID_LENGTH, + ], $midKey) + ); + + $this->addColumn($table, $column); + } + + /** @var array> $additionalColumns */ + $additionalColumns = $relationDefs->getParam('additionalColumns') ?? []; + + foreach ($additionalColumns as $fieldName => $fieldParams) { + $column = $this->columnPreparator->prepare( + AttributeDefs::fromRaw($fieldParams, $fieldName) + ); + + $this->addColumn($table, $column); + } + + $deletedColumn = $this->columnPreparator->prepare( + AttributeDefs::fromRaw([ + 'type' => Entity::BOOL, + 'default' => false, + ], self::ATTR_DELETED) + ); + + $this->addColumn($table, $deletedColumn); + + $table->setPrimaryKey([self::ATTR_ID]); + + $this->addIndexes($table, $relationDefs->getIndexList()); + + $tables[$relationshipName] = $table; + } + + /** + * @param IndexDefs[] $indexDefsList + * @throws SchemaException + */ + private function addIndexes(Table $table, array $indexDefsList): void + { + foreach ($indexDefsList as $indexDefs) { + $columns = array_map( + fn($item) => Util::toUnderScore($item), + $indexDefs->getColumnList() + ); + + if ($indexDefs->isUnique()) { + $table->addUniqueIndex($columns, $indexDefs->getKey()); + + continue; + } + + $table->addIndex($columns, $indexDefs->getKey(), $indexDefs->getFlagList()); + } + } + + /** + * @todo Move to a class. Add unit test. + * @return array + */ + private static function convertColumn(Column $column): array + { + $result = [ + 'notnull' => $column->isNotNull(), + ]; + + if ($column->getLength() !== null) { + $result['length'] = $column->getLength(); + } + + if ($column->getDefault() !== null) { + $result['default'] = $column->getDefault(); + } + + if ($column->getAutoincrement() !== null) { + $result['autoincrement'] = $column->getAutoincrement(); + } + + if ($column->getPrecision() !== null) { + $result['precision'] = $column->getPrecision(); + } + + if ($column->getScale() !== null) { + $result['scale'] = $column->getScale(); + } + + if ($column->getUnsigned() !== null) { + $result['unsigned'] = $column->getUnsigned(); + } + + // Can't use customSchemaOptions as it causes unwanted ALTER TABLE. + $result['platformOptions'] = []; + + if ($column->getCollation()) { + $result['platformOptions']['collation'] = $column->getCollation(); + } + + return $result; + } + + /** + * Get custom table definition in `application/Espo/Core/Utils/Database/Schema/tables`. + * This logic can be removed in the future. Usage of table files in not recommended. + * + * @return array> + */ + private function getCustomTables(): array + { + $customTables = $this->loadData($this->pathProvider->getCore() . $this->tablesPath); + + foreach ($this->metadata->getModuleList() as $moduleName) { + $modulePath = $this->pathProvider->getModule($moduleName) . $this->tablesPath; + + $customTables = Util::merge( + $customTables, + $this->loadData($modulePath) + ); + } + + /** @var array $customTables */ + $customTables = Util::merge( + $customTables, + $this->loadData($this->pathProvider->getCustom() . $this->tablesPath) + ); + + return $customTables; + } + + /** + * @param string[] $entityTypeList + * @param array $ormMeta + * @param string[] $depList + * @return string[] + */ + private function getDependentEntityTypeList(array $entityTypeList, array $ormMeta, array $depList = []): array + { + foreach ($entityTypeList as $entityType) { + if (in_array($entityType, $depList)) { + continue; + } + + $depList[] = $entityType; + + $entityDefs = EntityDefs::fromRaw($ormMeta[$entityType] ?? [], $entityType); + + foreach ($entityDefs->getRelationList() as $relationDefs) { + if (!$relationDefs->hasForeignEntityType()) { + continue; + } + + $itemEntityType = $relationDefs->getForeignEntityType(); + + if (in_array($itemEntityType, $depList)) { + continue; + } + + $depList = $this->getDependentEntityTypeList([$itemEntityType], $ormMeta, $depList); + } + } + + return $depList; + } + + /** + * @param string $path + * @return array> + */ + private function loadData(string $path): array + { + $tables = []; + + if (!file_exists($path)) { + return $tables; + } + + /** @var string[] $fileList */ + $fileList = $this->fileManager->getFileList($path, false, '\.php$', true); + + foreach ($fileList as $fileName) { + $itemPath = $path . '/' . $fileName; + + if (!$this->fileManager->isFile($itemPath)) { + continue; + } + + $fileData = $this->fileManager->getPhpContents($itemPath); + + if (!is_array($fileData)) { + continue; + } + + /** @var array> $tables */ + $tables = Util::merge($tables, $fileData); + } + + return $tables; + } +} diff --git a/application/Espo/Core/Utils/Database/Schema/Column.php b/application/Espo/Core/Utils/Database/Schema/Column.php new file mode 100644 index 0000000000..5c7402eea6 --- /dev/null +++ b/application/Espo/Core/Utils/Database/Schema/Column.php @@ -0,0 +1,169 @@ +name; + } + + public function getType(): string + { + return $this->type; + } + + public function isNotNull(): bool + { + return $this->notNull; + } + + public function getLength(): ?int + { + return $this->length; + } + + public function getDefault(): mixed + { + return $this->default; + } + + public function getAutoincrement(): ?bool + { + return $this->autoincrement; + } + + public function getUnsigned(): ?bool + { + return $this->unsigned; + } + + public function getPrecision(): ?int + { + return $this->precision; + } + + public function getScale(): ?int + { + return $this->scale; + } + + public function getCollation(): ?string + { + return $this->collation; + } + + public function withNotNull(bool $notNull = true): self + { + $obj = clone $this; + $obj->notNull = $notNull; + + return $obj; + } + + public function withLength(?int $length): self + { + $obj = clone $this; + $obj->length = $length; + + return $obj; + } + + public function withDefault(mixed $default): self + { + $obj = clone $this; + $obj->default = $default; + + return $obj; + } + + public function withAutoincrement(?bool $autoincrement = true): self + { + $obj = clone $this; + $obj->autoincrement = $autoincrement; + + return $obj; + } + + /** + * Supported only by MySQL. + */ + public function withUnsigned(?bool $unsigned = true): self + { + $obj = clone $this; + $obj->unsigned = $unsigned; + + return $obj; + } + + public function withPrecision(?int $precision): self + { + $obj = clone $this; + $obj->precision = $precision; + + return $obj; + } + + public function withScale(?int $scale): self + { + $obj = clone $this; + $obj->scale = $scale; + + return $obj; + } + + public function withCollation(?string $collation): self + { + $obj = clone $this; + $obj->collation = $collation; + + return $obj; + } +} diff --git a/application/Espo/Core/Utils/Database/Schema/ColumnPreparator.php b/application/Espo/Core/Utils/Database/Schema/ColumnPreparator.php new file mode 100644 index 0000000000..6b94aee29c --- /dev/null +++ b/application/Espo/Core/Utils/Database/Schema/ColumnPreparator.php @@ -0,0 +1,37 @@ + $className */ + $className = $this->metadata + ->get(['app', 'database', 'platforms', $platform, 'columnPreparatorClassName']); + + if (!$className) { + throw new RuntimeException("No Column-Preparator for {$platform}."); + } + + $binding = BindingContainerBuilder::create() + ->bindInstance(Helper::class, $this->helper) + ->build(); + + return $this->injectableFactory->createWithBinding($className, $binding); + } +} diff --git a/application/Espo/Core/Utils/Database/Schema/ColumnPreparators/MysqlColumnPreparator.php b/application/Espo/Core/Utils/Database/Schema/ColumnPreparators/MysqlColumnPreparator.php new file mode 100644 index 0000000000..fef20f05ba --- /dev/null +++ b/application/Espo/Core/Utils/Database/Schema/ColumnPreparators/MysqlColumnPreparator.php @@ -0,0 +1,216 @@ +getParam(self::PARAM_DB_TYPE) ?? $defs->getType(); + $columnName = Util::toUnderScore($defs->getName()); + + $column = Column::create($columnName, strtolower($columnType)); + + $type = $defs->getType(); + $length = $defs->getLength(); + $default = $defs->getParam(self::PARAM_DEFAULT); + $notNull = $defs->getParam(self::PARAM_NOT_NULL); + $autoincrement = $defs->getParam(self::PARAM_AUTOINCREMENT); + $precision = $defs->getParam(self::PARAM_PRECISION); + $scale = $defs->getParam(self::PARAM_SCALE); + $binary = $defs->getParam(self::PARAM_BINARY); + + if ($length !== null) { + $column = $column->withLength($length); + } + + if ($default !== null) { + $column = $column->withDefault($default); + } + + if ($notNull !== null) { + $column = $column->withNotNull($notNull); + } + + if ($autoincrement !== null) { + $column = $column->withAutoincrement($autoincrement); + } + + if ($precision !== null) { + $column = $column->withPrecision($precision); + } + + if ($scale !== null) { + $column = $column->withScale($scale); + } + + $mb3 = false; + + switch ($type) { + case Entity::ID: + case Entity::FOREIGN_ID: + case Entity::FOREIGN_TYPE: + $mb3 = $this->getMaxIndexLength() < self::MB4_INDEX_LENGTH_LIMIT; + + break; + + case Entity::TEXT: + case Entity::JSON_ARRAY: + $column = $column->withDefault(null); + + break; + + case Entity::BOOL: + $default = intval($default ?? false); + + $column = $column->withDefault($default); + + break; + } + + if ($type !== Entity::ID && $autoincrement) { + $column = $column + ->withNotNull() + ->withUnsigned(); + } + + $collation = $binary ? + 'utf8mb4_bin' : + 'utf8mb4_unicode_ci'; + + if ($mb3) { + $collation = $binary ? + 'utf8_bin' : + 'utf8_unicode_ci'; + } + + return $column->withCollation($collation); + } + + private function getMaxIndexLength(): int + { + if (!isset($this->maxIndexLength)) { + $this->maxIndexLength = $this->detectMaxIndexLength(); + } + + return $this->maxIndexLength; + } + + /** + * Get maximum index length. + */ + private function detectMaxIndexLength(): int + { + $databaseType = $this->helper->getType(); + + $tableEngine = $this->getTableEngine(); + + if (!$tableEngine) { + return self::DEFAULT_INDEX_LIMIT; + } + + switch ($tableEngine) { + case 'InnoDB': + $version = $this->helper->getVersion(); + + switch ($databaseType) { + case self::TYPE_MARIADB: + if (version_compare($version, '10.2.2') >= 0) { + return 3072; // InnoDB, MariaDB 10.2.2+ + } + + break; + + case self::TYPE_MYSQL: + return 3072; + } + + return 767; // InnoDB + } + + return 1000; // MyISAM + } + + /** + * Get a table or default engine. + */ + private function getTableEngine(): ?string + { + $databaseType = $this->helper->getType(); + + if (!in_array($databaseType, [self::TYPE_MYSQL, self::TYPE_MARIADB])) { + return null; + } + + $query = "SHOW TABLE STATUS WHERE Engine = 'MyISAM'"; + + $vars = []; + + $pdo = $this->helper->getPDO(); + + $sth = $pdo->prepare($query); + $sth->execute($vars); + + $result = $sth->fetchColumn(); + + if (!empty($result)) { + return 'MyISAM'; + } + + return 'InnoDB'; + } +} diff --git a/application/Espo/Core/Utils/Database/Schema/Converter.php b/application/Espo/Core/Utils/Database/Schema/Converter.php deleted file mode 100644 index fd80cac7dd..0000000000 --- a/application/Espo/Core/Utils/Database/Schema/Converter.php +++ /dev/null @@ -1,645 +0,0 @@ - doctrine - * @var array - */ - private $allowedDbFieldParams = [ - 'len' => 'length', - 'default' => 'default', - 'notNull' => 'notnull', - 'autoincrement' => 'autoincrement', - 'precision' => 'precision', - 'scale' => 'scale', - ]; - - /** - * @todo Same array in Converters\Orm. - * @var array - */ - private $idParams = [ - 'dbType' => 'varchar', - 'len' => 24, - ]; - - /** - * @todo Same array in Converters\Orm. - * @var array - */ - private $defaultLength = [ - 'varchar' => 255, - 'int' => 11, - ]; - - /** - * @var string[] - */ - private $notStorableTypes = [ - 'foreign', - ]; - - /** - * @var ?int - */ - private $maxIndexLength = null; - - public function __construct( - Metadata $metadata, - FileManager $fileManager, - Schema $databaseSchema, - Config $config, - Log $log, - PathProvider $pathProvider - ) { - $this->metadata = $metadata; - $this->fileManager = $fileManager; - $this->databaseSchema = $databaseSchema; - $this->config = $config; - $this->log = $log; - $this->pathProvider = $pathProvider; - - $this->typeList = array_keys(DbalType::getTypesMap()); - } - - private function getSchema(bool $reload = false): DbalSchema - { - if (!isset($this->dbalSchema) || $reload) { - $this->dbalSchema = new DbalSchema(); - } - - return $this->dbalSchema; - } - - private function getDatabaseSchema(): Schema - { - return $this->databaseSchema; - } - - private function getMaxIndexLength(): int - { - if (!isset($this->maxIndexLength)) { - $this->maxIndexLength = $this->getDatabaseSchema() - ->getDatabaseHelper() - ->getMaxIndexLength(); - } - - return $this->maxIndexLength; - } - - /** - * Schema conversation process. - * - * @param array $ormMeta - * @param string[]|string|null $entityList - * @throws SchemaException - */ - public function process(array $ormMeta, $entityList = null): DbalSchema - { - $this->log->debug('Schema\Converter - Start: building schema'); - - // Check if exist files in "Tables" directory and merge with ormMetadata. - - /** @var array $ormMeta */ - $ormMeta = Util::merge($ormMeta, $this->getCustomTables($ormMeta)); - - if (isset($ormMeta['unsetIgnore'])) { - $protectedOrmMeta = []; - - foreach ($ormMeta['unsetIgnore'] as $protectedKey) { - $protectedOrmMeta = Util::merge( - $protectedOrmMeta, - Util::fillArrayKeys($protectedKey, Util::getValueByKey($ormMeta, $protectedKey)) - ); - } - - unset($ormMeta['unsetIgnore']); - } - - // unset some keys in orm - if (isset($ormMeta['unset'])) { - /** @var array $ormMeta */ - $ormMeta = Util::unsetInArray($ormMeta, $ormMeta['unset']); - - unset($ormMeta['unset']); - } - - if (isset($protectedOrmMeta)) { - /** @var array $ormMeta */ - $ormMeta = Util::merge($ormMeta, $protectedOrmMeta); - } - - if (isset($entityList)) { - $entityList = is_string($entityList) ? (array) $entityList : $entityList; - - $dependentEntities = $this->getDependentEntities($entityList, $ormMeta); - - $this->log->debug( - 'Rebuild Database for entities: [' . - implode(', ', $entityList) . '] with dependent entities: [' . - implode(', ', $dependentEntities) . ']' - ); - - $ormMeta = array_intersect_key($ormMeta, array_flip($dependentEntities)); - } - - $schema = $this->getSchema(true); - - $indexes = SchemaUtils::getIndexes($ormMeta); - - $tables = []; - - foreach ($ormMeta as $entityName => $entityParams) { - if ($entityParams['skipRebuild'] ?? false) { - continue; - } - - $tableName = Util::toUnderScore($entityName); - - if ($schema->hasTable($tableName)) { - if (!isset($tables[$entityName])) { - $tables[$entityName] = $schema->getTable($tableName); - } - - $this->log->debug('DBAL: Table ['.$tableName.'] exists.'); - - continue; - } - - $tables[$entityName] = $schema->createTable($tableName); - - if (isset($entityParams['params']) && is_array($entityParams['params'])) { - foreach ($entityParams['params'] as $paramName => $paramValue) { - $tables[$entityName]->addOption($paramName, $paramValue); - } - } - - $primaryColumns = []; - - foreach ($entityParams['fields'] as $fieldName => $fieldParams) { - if ( - (isset($fieldParams['notStorable']) && $fieldParams['notStorable']) || - in_array($fieldParams['type'], $this->notStorableTypes) - ) { - continue; - } - - switch ($fieldParams['type']) { - case 'id': - $primaryColumns[] = Util::toUnderScore($fieldName); - - break; - } - - $fieldType = $fieldParams['dbType'] ?? $fieldParams['type']; - - /** Doctrine uses 'strtolower' for all field types. */ - $fieldType = strtolower($fieldType); - - if (!in_array($fieldType, $this->typeList)) { - $this->log->debug( - 'Converters\Schema::process(): Field type [' . $fieldType . '] does not exist '. - $entityName.':'.$fieldName - ); - - continue; - } - - $columnName = Util::toUnderScore($fieldName); - - if (!$tables[$entityName]->hasColumn($columnName)) { - $tables[$entityName]->addColumn($columnName, $fieldType, $this->getDbFieldParams($fieldParams)); - } - } - - $tables[$entityName]->setPrimaryKey($primaryColumns); - - if (!empty($indexes[$entityName])) { - $this->addIndexes($tables[$entityName], $indexes[$entityName]); - } - } - - // Check and create columns/tables for relations. - foreach ($ormMeta as $entityName => $entityParams) { - if (!isset($entityParams['relations'])) { - continue; - } - - foreach ($entityParams['relations'] as $relationName => $relationParams) { - switch ($relationParams['type']) { - case 'manyMany': - $tableName = $relationParams['relationName']; - - // Check for duplicate tables. - if (!isset($tables[$tableName])) { - // No needs to create a table if it already exists. - $tables[$tableName] = $this->prepareManyMany($entityName, $relationParams); - } - - break; - } - } - } - - $this->log->debug('Schema\Converter - End: building schema'); - - return $schema; - } - - /** - * Prepare a relation table for the manyMany relation. - * - * @param string $entityName - * @param array $relationParams - * @throws SchemaException - */ - private function prepareManyMany(string $entityName, $relationParams): Table - { - $relationName = $relationParams['relationName']; - - $tableName = Util::toUnderScore($relationName); - - $this->log->debug('DBAL: prepareManyMany invoked for ' . $entityName, [ - 'tableName' => $tableName, 'parameters' => $relationParams - ]); - - if ($this->getSchema()->hasTable($tableName)) { - $this->log->debug('DBAL: Table ['.$tableName.'] exists.'); - - return $this->getSchema()->getTable($tableName); - } - - $table = $this->getSchema()->createTable($tableName); - - $idColumnOptions = $this->getDbFieldParams([ - 'type' => 'id', - 'len' => 20, - 'autoincrement' => true, - ]); - - $table->addColumn('id', 'bigint', $idColumnOptions); - - // Add midKeys to the schema. - $uniqueIndex = []; - - if (empty($relationParams['midKeys'])) { - $this->log->debug('REBUILD: midKeys are empty!', [ - 'scope' => $entityName, 'tableName' => $tableName, - 'parameters' => $relationParams - ]); - } - else { - foreach ($relationParams['midKeys'] as $midKey) { - $columnName = Util::toUnderScore($midKey); - - $table->addColumn( - $columnName, - $this->idParams['dbType'], - $this->getDbFieldParams([ - 'type' => 'foreignId', - 'len' => $this->idParams['len'], - ]) - ); - - $indexDefs = IndexDefs::fromRaw([], $columnName); - - $indexName = SchemaUtils::generateIndexName($indexDefs, $relationName); - - $table->addIndex([$columnName], $indexName); - - $uniqueIndex[] = $columnName; - } - } - - if (!empty($relationParams['additionalColumns'])) { - foreach($relationParams['additionalColumns'] as $fieldName => $fieldParams) { - if (!isset($fieldParams['type'])) { - $fieldParams = array_merge($fieldParams, [ - 'type' => 'varchar', - 'len' => $this->defaultLength['varchar'], - ]); - } - - $table->addColumn( - Util::toUnderScore($fieldName), - $fieldParams['type'], - $this->getDbFieldParams($fieldParams) - ); - } - } - - $table->addColumn( - 'deleted', - 'bool', - $this->getDbFieldParams([ - 'type' => 'bool', - 'default' => false, - ]) - ); - - $table->setPrimaryKey(['id']); - - // Add defined indexes. - if (!empty($relationParams['indexes'])) { - $normalizedIndexes = SchemaUtils::getIndexes([ - $entityName => [ - 'indexes' => $relationParams['indexes'] - ] - ]); - - $this->addIndexes($table, $normalizedIndexes[$entityName]); - } - - // Add unique indexes. - if (!empty($relationParams['conditions'])) { - foreach ($relationParams['conditions'] as $fieldName => $fieldParams) { - $uniqueIndex[] = Util::toUnderScore($fieldName); - } - } - - if (!empty($uniqueIndex)) { - /** @var string[] $uniqueIndex */ - $uniqueIndexName = implode('_', $uniqueIndex); - - $indexDefs = IndexDefs::fromRaw(['type' => 'unique'], $uniqueIndexName); - - $indexName = SchemaUtils::generateIndexName($indexDefs, $relationName); - - $table->addUniqueIndex($uniqueIndex, $indexName); - } - - return $table; - } - - /** - * @param array> $indexes - * @throws SchemaException - */ - private function addIndexes(Table $table, array $indexes): void - { - foreach ($indexes as $indexName => $indexParams) { - $indexDefs = IndexDefs::fromRaw($indexParams, $indexName); - - if ($indexDefs->isUnique()) { - $table->addUniqueIndex($indexDefs->getColumnList(), $indexName); - - continue; - } - - $table->addIndex($indexDefs->getColumnList(), $indexName, $indexDefs->getFlagList()); - } - } - - /** - * @param array $fieldParams - * @return array - */ - private function getDbFieldParams($fieldParams) - { - $dbFieldParams = [ - 'notnull' => false, - ]; - - foreach ($this->allowedDbFieldParams as $espoName => $dbalName) { - if (isset($fieldParams[$espoName])) { - $dbFieldParams[$dbalName] = $fieldParams[$espoName]; - } - } - - $databaseParams = $this->config->get('database'); - - if (!isset($databaseParams['charset']) || $databaseParams['charset'] == 'utf8mb4') { - $dbFieldParams['platformOptions'] = [ - 'collation' => 'utf8mb4_unicode_ci', - ]; - } - - switch ($fieldParams['type']) { - case 'id': - case 'foreignId': - case 'foreignType': - if ($this->getMaxIndexLength() < 3072) { - $fieldParams['utf8mb3'] = true; - } - - break; - - case 'array': - case 'jsonArray': - case 'text': - case 'longtext': - unset($dbFieldParams['default']); // for db type TEXT can't be defined a default value - - break; - - case 'bool': - $default = false; - - if (array_key_exists('default', $dbFieldParams)) { - $default = $dbFieldParams['default']; - } - - $dbFieldParams['default'] = intval($default); - - break; - } - - if ( - $fieldParams['type'] != 'id' && - isset($fieldParams['autoincrement']) && - $fieldParams['autoincrement'] - ) { - $dbFieldParams['notnull'] = true; - $dbFieldParams['unsigned'] = true; - } - - if (isset($fieldParams['binary']) && $fieldParams['binary']) { - $dbFieldParams['platformOptions'] = [ - 'collation' => 'utf8mb4_bin', - ]; - } - - if (isset($fieldParams['utf8mb3']) && $fieldParams['utf8mb3']) { - $dbFieldParams['platformOptions'] = [ - 'collation' => - (isset($fieldParams['binary']) && $fieldParams['binary']) ? - 'utf8_bin' : - 'utf8_unicode_ci', - ]; - } - - return $dbFieldParams; - } - - /** - * Get custom table definition in - * `application/Espo/Core/Utils/Database/Schema/tables/` and in metadata 'additionalTables'. - * - * @param array $ormMeta - * @return array> - */ - private function getCustomTables(array $ormMeta): array - { - $customTables = $this->loadData($this->pathProvider->getCore() . $this->tablesPath); - - foreach ($this->metadata->getModuleList() as $moduleName) { - $modulePath = $this->pathProvider->getModule($moduleName) . $this->tablesPath; - - $customTables = Util::merge( - $customTables, - $this->loadData($modulePath) - ); - } - - /** @var array $customTables */ - $customTables = Util::merge( - $customTables, - $this->loadData($this->pathProvider->getCustom() . $this->tablesPath) - ); - - // Get custom tables from metadata 'additionalTables'. - foreach ($ormMeta as $entityParams) { - if (isset($entityParams['additionalTables']) && is_array($entityParams['additionalTables'])) { - /** @var array $customTables */ - $customTables = Util::merge($customTables, $entityParams['additionalTables']); - } - } - - return $customTables; - } - - /** - * - * @param string[]|string $entityList - * @param array $ormMeta - * @param string[] $dependentEntities - * @return string[] - */ - private function getDependentEntities($entityList, $ormMeta, $dependentEntities = []) - { - if (is_string($entityList)) { - $entityList = (array) $entityList; - } - - foreach ($entityList as $entityName) { - if (in_array($entityName, $dependentEntities)) { - continue; - } - - $dependentEntities[] = $entityName; - - if (array_key_exists('relations', $ormMeta[$entityName])) { - foreach ($ormMeta[$entityName]['relations'] as $relationName => $relationParams) { - if (isset($relationParams['entity'])) { - $relationEntity = $relationParams['entity']; - - if (!in_array($relationEntity, $dependentEntities)) { - $dependentEntities = $this->getDependentEntities( - $relationEntity, - $ormMeta, - $dependentEntities - ); - } - } - } - } - - } - - return $dependentEntities; - } - - /** - * @param string $path - * @return array> - */ - private function loadData(string $path): array - { - $tables = []; - - if (!file_exists($path)) { - return $tables; - } - - /** @var string[] $fileList */ - $fileList = $this->fileManager->getFileList($path, false, '\.php$', true); - - foreach ($fileList as $fileName) { - $itemPath = $path . '/' . $fileName; - - if (!$this->fileManager->isFile($itemPath)) { - continue; - } - - $fileData = $this->fileManager->getPhpContents($itemPath); - - if (!is_array($fileData)) { - continue; - } - - /** @var array> $tables */ - $tables = Util::merge($tables, $fileData); - } - - return $tables; - } -} diff --git a/application/Espo/Core/Utils/Database/Schema/MetadataProvider.php b/application/Espo/Core/Utils/Database/Schema/MetadataProvider.php new file mode 100644 index 0000000000..81bff1b8eb --- /dev/null +++ b/application/Espo/Core/Utils/Database/Schema/MetadataProvider.php @@ -0,0 +1,66 @@ +configDataProvider->getPlatform(); + } + + /** + * @return class-string[] + */ + public function getPreRebuildActionClassNameList(): array + { + /** @var class-string[] */ + return $this->metadata + ->get(['app', 'database', 'platforms', $this->getPlatform(), 'preRebuildActionClassNameList']) ?? []; + } + + /** + * @return class-string[] + */ + public function getPostRebuildActionClassNameList(): array + { + /** @var class-string[] */ + return $this->metadata + ->get(['app', 'database', 'platforms', $this->getPlatform(), 'postRebuildActionClassNameList']) ?? []; + } +} diff --git a/application/Espo/Core/Utils/Database/Schema/RebuildAction.php b/application/Espo/Core/Utils/Database/Schema/RebuildAction.php new file mode 100644 index 0000000000..7894349dd6 --- /dev/null +++ b/application/Espo/Core/Utils/Database/Schema/RebuildAction.php @@ -0,0 +1,37 @@ +getCurrentSchema(); - - if (!$currentSchema) { - throw new RuntimeException(); - } - - $tables = $currentSchema->getTables(); - - if (empty($tables)) { + if ($oldSchema->getTables() === []) { return; } - $databaseHelper = $this->injectableFactory->create(Helper::class); + $connection = $this->helper->getDbalConnection(); + $pdo = $this->helper->getPDO(); - $connection = $databaseHelper->getDbalConnection(); - - $metadataSchema = $this->getMetadataSchema(); - - if (!$metadataSchema) { - throw new RuntimeException(); - } - - $tables = $metadataSchema->getTables(); - - foreach ($tables as $table) { + foreach ($newSchema->getTables() as $table) { $tableName = $table->getName(); $indexes = $table->getIndexes(); @@ -83,26 +68,25 @@ class FulltextIndex extends BaseRebuildActions implements Di\InjectableFactoryAw $columns = $index->getColumns(); foreach ($columns as $columnName) { - - $query = "SHOW FULL COLUMNS FROM `". $tableName ."` WHERE Field = '" . $columnName . "'"; + $sql = "SHOW FULL COLUMNS FROM `" . $tableName . "` WHERE Field = " . $pdo->quote($columnName); try { /** @var array{Type: string, Collation: string} $row */ - $row = $connection->fetchAssociative($query); + $row = $connection->fetchAssociative($sql); } - catch (Exception $e) { + catch (Exception) { continue; } switch (strtoupper($row['Type'])) { case 'LONGTEXT': - $alterQuery = - "ALTER TABLE `". $tableName ."` " . - "MODIFY `". $columnName ."` MEDIUMTEXT COLLATE ". $row['Collation'] .""; + $alterSql = + "ALTER TABLE `{$tableName}` " . + "MODIFY `{$columnName}` MEDIUMTEXT COLLATE " . $row['Collation']; - $this->log->info('SCHEMA, Execute Query: ' . $alterQuery); + $this->log->info('SCHEMA, Execute Query: ' . $alterSql); - $connection->executeQuery($alterQuery); + $connection->executeQuery($alterSql); break; } diff --git a/application/Espo/Core/Utils/Database/Schema/Schema.php b/application/Espo/Core/Utils/Database/Schema/SchemaManager.php similarity index 52% rename from application/Espo/Core/Utils/Database/Schema/Schema.php rename to application/Espo/Core/Utils/Database/Schema/SchemaManager.php index 40d8526a4a..4d8ac46ac2 100644 --- a/application/Espo/Core/Utils/Database/Schema/Schema.php +++ b/application/Espo/Core/Utils/Database/Schema/SchemaManager.php @@ -29,84 +29,65 @@ namespace Espo\Core\Utils\Database\Schema; -use Doctrine\DBAL\Connection; +use Doctrine\DBAL\Connection as DbalConnection; use Doctrine\DBAL\Platforms\AbstractPlatform; -use Doctrine\DBAL\Schema\Schema as DBALSchema; -use Doctrine\DBAL\Schema\SchemaDiff as DBALSchemaDiff; +use Doctrine\DBAL\Schema\Schema as DbalSchema; +use Doctrine\DBAL\Schema\SchemaDiff as DbalSchemaDiff; use Doctrine\DBAL\Schema\SchemaException; use Doctrine\DBAL\Types\Type; +use Espo\Core\Binding\BindingContainerBuilder; use Espo\Core\InjectableFactory; -use Espo\Core\Utils\Config; -use Espo\Core\Utils\Database\Converter as DatabaseConverter; use Espo\Core\Utils\Database\DBAL\Schema\Comparator; use Espo\Core\Utils\Database\Helper; -use Espo\Core\Utils\File\ClassMap; use Espo\Core\Utils\File\Manager as FileManager; use Espo\Core\Utils\Log; -use Espo\Core\Utils\Metadata; use Espo\Core\Utils\Metadata\OrmMetadataData; -use Espo\Core\Utils\Module\PathProvider; use Espo\Core\Utils\Util; use Throwable; -class Schema +/** + * A database schema manager. + */ +class SchemaManager { private string $fieldTypePath = 'application/Espo/Core/Utils/Database/DBAL/FieldTypes'; - private string $rebuildActionsPath = 'Core/Utils/Database/Schema/rebuildActions'; private Comparator $comparator; - private DatabaseConverter $converter; - private Converter $schemaConverter; - - /** - * @var ?array{ - * beforeRebuild: BaseRebuildActions[], - * afterRebuild: BaseRebuildActions[], - * } - */ - private $rebuildActions = null; + private Builder $builder; public function __construct( - private Config $config, - private Metadata $metadata, private FileManager $fileManager, - private ClassMap $classMap, - protected OrmMetadataData $ormMetadataData, + private OrmMetadataData $ormMetadataData, private Log $log, - PathProvider $pathProvider, - DatabaseConverter $databaseConverter, - private Helper $databaseHelper, + private Helper $helper, + private MetadataProvider $metadataProvider, private InjectableFactory $injectableFactory ) { - $this->converter = $databaseConverter; - $this->comparator = new Comparator(); $this->initFieldTypes(); - $this->schemaConverter = new Converter( - $this->metadata, - $this->fileManager, - $this, - $this->config, - $this->log, - $pathProvider + $this->builder = $this->injectableFactory->createWithBinding( + Builder::class, + BindingContainerBuilder::create() + ->bindInstance(Helper::class, $this->helper) + ->build() ); } public function getDatabaseHelper(): Helper { - return $this->databaseHelper; + return $this->helper; } - public function getPlatform(): AbstractPlatform + private function getPlatform(): AbstractPlatform { - return $this->getConnection()->getDatabasePlatform(); + return $this->getDbalConnection()->getDatabasePlatform(); } - public function getConnection(): Connection + private function getDbalConnection(): DbalConnection { return $this->getDatabaseHelper()->getDbalConnection(); } @@ -123,7 +104,7 @@ class Schema $filePath = Util::concatPath($this->fieldTypePath, $typeName . 'Type'); - /** @var class-string<\Doctrine\DBAL\Types\Type> $class */ + /** @var class-string $class */ $class = Util::getClassName($filePath); if (!Type::hasType($dbalTypeName)) { @@ -143,75 +124,71 @@ class Schema $dbTypeName = $dbalTypeName; } - $this->getConnection() + $this->getDbalConnection() ->getDatabasePlatform() ->registerDoctrineTypeMapping($dbTypeName, $dbalTypeName); } } /** - * Rebuild database schema. + * Rebuild database schema. Creates and alters needed tables and columns. + * Does not remove columns, does not decrease column lengths. * - * @param ?string[] $entityList + * @param ?string[] $entityTypeList Specific entity types. * @throws SchemaException */ - public function rebuild(?array $entityList = null): bool + public function rebuild(?array $entityTypeList = null): bool { - if (!$this->converter->process()) { - return false; - } - $currentSchema = $this->getCurrentSchema(); - $metadataSchema = $this->schemaConverter->process($this->ormMetadataData->getData(), $entityList); - - $this->initRebuildActions($currentSchema, $metadataSchema); + $schema = $this->builder->build($this->ormMetadataData->getData(), $entityTypeList); try { - $this->executeRebuildActions('beforeRebuild'); + $this->processPreRebuildActions($currentSchema, $schema); } catch (Throwable $e) { - $this->log->alert('Rebuild database fault: '. $e); + $this->log->alert('Rebuild database pre-rebuild error: '. $e->getMessage()); return false; } - $queries = $this->getDiffSql($currentSchema, $metadataSchema); + $queries = $this->getDiffSql($currentSchema, $schema); $result = true; - $connection = $this->getConnection(); + + $connection = $this->getDbalConnection(); foreach ($queries as $sql) { - $this->log->info('SCHEMA, Execute Query: '.$sql); + $this->log->info('SCHEMA, Execute Query: '. $sql); try { - $result &= (bool) $connection->executeQuery($sql); + $connection->executeQuery($sql); } catch (Throwable $e) { - $this->log->alert('Rebuild database fault: '. $e); + $this->log->alert('Rebuild database error: ' . $e->getMessage()); $result = false; } } try { - $this->executeRebuildActions('afterRebuild'); + $this->processPostRebuildActions($currentSchema, $schema); } catch (Throwable $e) { - $this->log->alert('Rebuild database fault: '. $e); + $this->log->alert('Rebuild database post-rebuild error: ' . $e->getMessage()); return false; } - return (bool) $result; + return $result; } /** * Get current database schema. */ - private function getCurrentSchema(): DBALSchema + private function getCurrentSchema(): DbalSchema { - return $this->getConnection() + return $this->getDbalConnection() ->getSchemaManager() ->createSchema(); } @@ -221,7 +198,7 @@ class Schema * * @return string[] Array of SQL queries. */ - public function toSql(DBALSchemaDiff $schema) + private function toSql(DbalSchemaDiff $schema) { return $schema->toSaveSql($this->getPlatform()); } @@ -232,67 +209,36 @@ class Schema * @return string[] Array of SQL queries. * @throws SchemaException */ - public function getDiffSql(DBALSchema $fromSchema, DBALSchema $toSchema) + private function getDiffSql(DbalSchema $fromSchema, DbalSchema $toSchema) { $schemaDiff = $this->comparator->compare($fromSchema, $toSchema); return $this->toSql($schemaDiff); } - /** - * Init Rebuild Actions, get all classes and create them. - */ - private function initRebuildActions(?DBALSchema $currentSchema = null, ?DBALSchema $metadataSchema = null): void + private function processPreRebuildActions(DbalSchema $actualSchema, DbalSchema $schema): void { - $methodList = [ - 'beforeRebuild', - 'afterRebuild', - ]; + $binding = BindingContainerBuilder::create() + ->bindInstance(Helper::class, $this->helper) + ->build(); - /** @var array> $classes */ - $classes = $this->classMap->getData($this->rebuildActionsPath, null, $methodList); + foreach ($this->metadataProvider->getPreRebuildActionClassNameList() as $className) { + $action = $this->injectableFactory->createWithBinding($className, $binding); - $objects = [ - 'beforeRebuild' => [], - 'afterRebuild' => [], - ]; - - foreach ($classes as $className) { - $actionObj = $this->injectableFactory->create($className); - - if (isset($currentSchema)) { - $actionObj->setCurrentSchema($currentSchema); - } - - if (isset($metadataSchema)) { - $actionObj->setMetadataSchema($metadataSchema); - } - - foreach ($methodList as $methodName) { - if (method_exists($actionObj, $methodName)) { - $objects[$methodName][] = $actionObj; - } - } + $action->process($actualSchema, $schema); } - - $this->rebuildActions = $objects; } - /** - * Execute actions for RebuildAction classes. - * - * @param 'beforeRebuild'|'afterRebuild' $action An action name. - */ - private function executeRebuildActions(string $action): void + private function processPostRebuildActions(DbalSchema $actualSchema, DbalSchema $schema): void { - if (!isset($this->rebuildActions)) { - $this->initRebuildActions(); - } + $binding = BindingContainerBuilder::create() + ->bindInstance(Helper::class, $this->helper) + ->build(); - assert($this->rebuildActions !== null); + foreach ($this->metadataProvider->getPostRebuildActionClassNameList() as $className) { + $action = $this->injectableFactory->createWithBinding($className, $binding); - foreach ($this->rebuildActions[$action] as $rebuildActionClass) { - $rebuildActionClass->$action(); + $action->process($actualSchema, $schema); } } } diff --git a/application/Espo/Core/Utils/Database/Schema/SchemaProxy.php b/application/Espo/Core/Utils/Database/Schema/SchemaManagerProxy.php similarity index 71% rename from application/Espo/Core/Utils/Database/Schema/SchemaProxy.php rename to application/Espo/Core/Utils/Database/Schema/SchemaManagerProxy.php index c93a5740b9..682e07bda2 100644 --- a/application/Espo/Core/Utils/Database/Schema/SchemaProxy.php +++ b/application/Espo/Core/Utils/Database/Schema/SchemaManagerProxy.php @@ -29,37 +29,31 @@ namespace Espo\Core\Utils\Database\Schema; -use Espo\Core\{ - Container, - Utils\Database\Helper, -}; +use Espo\Core\InjectableFactory; +use Espo\Core\Utils\Database\Helper; -class SchemaProxy +use Doctrine\DBAL\Schema\SchemaException; + +class SchemaManagerProxy { - private Container $container; + public function __construct(private InjectableFactory $injectableFactory) {} - public function __construct(Container $container) + private function getSchemaManager(): SchemaManager { - $this->container = $container; - } - - protected function getSchema(): Schema - { - /** @var Schema */ - return $this->container->get('schema'); + return $this->injectableFactory->create(SchemaManager::class); } /** - * @param ?string[] $entityList - * @throws \Doctrine\DBAL\Schema\SchemaException + * @param ?string[] $entityTypeList + * @throws SchemaException */ - public function rebuild(?array $entityList = null): bool + public function rebuild(?array $entityTypeList = null): bool { - return $this->getSchema()->rebuild(); + return $this->getSchemaManager()->rebuild($entityTypeList); } public function getDatabaseHelper(): Helper { - return $this->getSchema()->getDatabaseHelper(); + return $this->getSchemaManager()->getDatabaseHelper(); } } diff --git a/application/Espo/Core/Utils/Database/Schema/Utils.php b/application/Espo/Core/Utils/Database/Schema/Utils.php index 2b3905a09f..7b4b35e662 100644 --- a/application/Espo/Core/Utils/Database/Schema/Utils.php +++ b/application/Espo/Core/Utils/Database/Schema/Utils.php @@ -36,6 +36,7 @@ class Utils { /** * Get indexes in specific format. + * @deprecated * * @param array $defs * @param string[] $ignoreFlags @todo Remove parameter? @@ -46,52 +47,48 @@ class Utils $indexList = []; foreach ($defs as $entityType => $entityParams) { - $entityIndexList = self::getEntityIndexListByFieldsDefs($entityParams['fields'] ?? []); + $indexes = $entityParams['indexes'] ?? []; - foreach ($entityIndexList as $indexName => $indexParams) { - if (!isset($entityParams['indexes'][$indexName])) { - $entityParams['indexes'][$indexName] = $indexParams; + foreach ($indexes as $indexName => $indexParams) { + $indexDefs = IndexDefs::fromRaw($indexParams, $indexName); + + $tableIndexName = $indexParams['key'] ?? null; + + if (!$tableIndexName) { + continue; } - } - if (isset($entityParams['indexes']) && is_array($entityParams['indexes'])) { - foreach ($entityParams['indexes'] as $indexName => $indexParams) { - $indexDefs = IndexDefs::fromRaw($indexParams, $indexName); + $columns = $indexDefs->getColumnList(); + $flags = $indexDefs->getFlagList(); - $tableIndexName = $indexParams['key'] ?? self::generateIndexName($indexDefs, $entityType); + if ($flags !== []) { + $skipIndex = false; - $columns = $indexDefs->getColumnList(); - $flags = $indexDefs->getFlagList(); + foreach ($ignoreFlags as $ignoreFlag) { + if (($flagKey = array_search($ignoreFlag, $flags)) !== false) { + unset($flags[$flagKey]); - if ($flags !== []) { - $skipIndex = false; - - foreach ($ignoreFlags as $ignoreFlag) { - if (($flagKey = array_search($ignoreFlag, $flags)) !== false) { - unset($flags[$flagKey]); - - $skipIndex = true; - } + $skipIndex = true; } - - if ($skipIndex && empty($flags)) { - continue; - } - - $indexList[$entityType][$tableIndexName]['flags'] = $flags; } - if ($columns !== []) { - $indexType = self::getIndexTypeByIndexDefs($indexDefs); - - // @todo Revise, may to be removed. - $indexList[$entityType][$tableIndexName]['type'] = $indexType; - - $indexList[$entityType][$tableIndexName]['columns'] = array_map( - fn ($item) => Util::toUnderScore($item), - $columns - ); + if ($skipIndex && empty($flags)) { + continue; } + + $indexList[$entityType][$tableIndexName]['flags'] = $flags; + } + + if ($columns !== []) { + $indexType = self::getIndexTypeByIndexDefs($indexDefs); + + // @todo Revise, may to be removed. + $indexList[$entityType][$tableIndexName]['type'] = $indexType; + + $indexList[$entityType][$tableIndexName]['columns'] = array_map( + fn ($item) => Util::toUnderScore($item), + $columns + ); } } } @@ -100,82 +97,6 @@ class Utils return $indexList; } - /** - * @param array $fieldDefs - */ - private static function getIndexTypeByFieldDefs(array $fieldDefs): ?string - { - if ($fieldDefs['type'] != 'id' && isset($fieldDefs['unique']) && $fieldDefs['unique']) { - return 'unique'; - } - - if (isset($fieldDefs['index']) && $fieldDefs['index']) { - return 'index'; - } - - return null; - } - - /** - * @param array $fieldDefs - */ - private static function getIndexNameByFieldDefs(string $fieldName, array $fieldDefs): ?string - { - $indexType = self::getIndexTypeByFieldDefs($fieldDefs); - - if ($indexType) { - $keyValue = $fieldDefs[$indexType]; - - if ($keyValue === true) { - return $fieldName; - } - - if (is_string($keyValue)) { - return $keyValue; - } - } - - return null; - } - - /** - * @param array $fieldsDefs - * @return array - */ - public static function getEntityIndexListByFieldsDefs(array $fieldsDefs, bool $isTableColumnNames = false): array - { - $indexList = []; - - foreach ($fieldsDefs as $fieldName => $fieldParams) { - if (isset($fieldParams['notStorable']) && $fieldParams['notStorable']) { - continue; - } - - $indexType = self::getIndexTypeByFieldDefs($fieldParams); - $indexName = self::getIndexNameByFieldDefs($fieldName, $fieldParams); - - if (!$indexType || !$indexName) { - continue; - } - - $keyValue = $fieldParams[$indexType]; - - $columnName = $isTableColumnNames ? Util::toUnderScore($fieldName) : $fieldName; - - if ($keyValue === true) { - $indexList[$indexName]['type'] = $indexType; - $indexList[$indexName]['columns'] = [$columnName]; - } - else if (is_string($keyValue)) { - $indexList[$indexName]['type'] = $indexType; - $indexList[$indexName]['columns'][] = $columnName; - } - } - - /** @var array */ - return $indexList; - } - private static function getIndexTypeByIndexDefs(IndexDefs $indexDefs): string { if ($indexDefs->isUnique()) { @@ -189,23 +110,6 @@ class Utils return 'index'; } - /** - * @todo Move to IndexHelper interface. - */ - public static function generateIndexName(IndexDefs $defs, string $entityType): string - { - $maxLength = 60; - - $name = $defs->getName(); - $prefix = $defs->isUnique() ? 'UNIQ' : 'IDX'; - - $parts = [$prefix, strtoupper(Util::toUnderScore($name))]; - - $key = implode('_', $parts); - - return substr($key, 0, $maxLength); - } - /** * @deprecated * diff --git a/application/Espo/Core/Utils/Metadata/OrmMetadataData.php b/application/Espo/Core/Utils/Metadata/OrmMetadataData.php index 08c3266d35..b389baf79a 100644 --- a/application/Espo/Core/Utils/Metadata/OrmMetadataData.php +++ b/application/Espo/Core/Utils/Metadata/OrmMetadataData.php @@ -31,42 +31,28 @@ namespace Espo\Core\Utils\Metadata; use Espo\Core\InjectableFactory; use Espo\Core\Utils\Config; -use Espo\Core\Utils\Database\Converter; +use Espo\Core\Utils\Database\Orm\Converter; use Espo\Core\Utils\DataCache; -use Espo\Core\Utils\File\Manager as FileManager; -use Espo\Core\Utils\Metadata; use Espo\Core\Utils\Util; class OrmMetadataData { /** @var ?array> */ - protected $data = null; - - protected string $cacheKey = 'ormMetadata'; - - protected bool $useCache; - protected Metadata $metadata; - protected FileManager $fileManager; - protected DataCache $dataCache; - protected Config $config; + private $data = null; + private string $cacheKey = 'ormMetadata'; + private bool $useCache; private ?Converter $converter = null; public function __construct( - Metadata $metadata, - FileManager $fileManager, - DataCache $dataCache, - Config $config, + private DataCache $dataCache, + private Config $config, private InjectableFactory $injectableFactory ) { - $this->metadata = $metadata; - $this->fileManager = $fileManager; - $this->dataCache = $dataCache; - $this->config = $config; $this->useCache = (bool) $this->config->get('useCache', false); } - protected function getConverter(): Converter + private function getConverter(): Converter { if (!isset($this->converter)) { $this->converter = $this->injectableFactory->create(Converter::class); @@ -76,9 +62,27 @@ class OrmMetadataData } /** + * Reloads data. + */ + public function reload(): void + { + $this->getDataInternal(true); + } + + /** + * Get raw data. + * * @return array> */ - public function getData(bool $reload = false): array + public function getData(): array + { + return $this->getDataInternal(); + } + + /** + * @return array> + */ + private function getDataInternal(bool $reload = false): array { if (isset($this->data) && !$reload) { return $this->data; diff --git a/application/Espo/Core/Utils/SystemRequirements.php b/application/Espo/Core/Utils/SystemRequirements.php index f038eeddd2..462d0e9a00 100644 --- a/application/Espo/Core/Utils/SystemRequirements.php +++ b/application/Espo/Core/Utils/SystemRequirements.php @@ -29,25 +29,25 @@ namespace Espo\Core\Utils; +use Espo\Core\ORM\DatabaseParamsFactory; use Espo\Core\Utils\Database\Helper as DatabaseHelper; use Espo\Core\Utils\File\Manager as FileManager; -use PDO; - class SystemRequirements { public function __construct( private Config $config, private FileManager $fileManager, private System $systemHelper, - private DatabaseHelper $databaseHelper + private DatabaseHelper $databaseHelper, + private DatabaseParamsFactory $databaseParamsFactory ) {} /** * @return array{ - * php: array>, - * database: array>, - * permission: array>, + * php: array>, + * database: array>, + * permission: array>, * } */ public function getAllRequiredList(bool $requiredOnly = false): array @@ -55,16 +55,16 @@ class SystemRequirements return [ 'php' => $this->getPhpRequiredList($requiredOnly), 'database' => $this->getDatabaseRequiredList($requiredOnly), - 'permission' => $this->getRequiredPermissionList($requiredOnly), + 'permission' => $this->getRequiredPermissionList(), ]; } /** - * @param array $additionalData + * @param array $additionalData * @return array{ - * php?: array>, - * database?: array>, - * permission?: array, + * php?: array>, + * database?: array>, + * permission?: array, * } */ public function getRequiredListByType( @@ -73,27 +73,20 @@ class SystemRequirements array $additionalData = null ): array { - switch ($type) { - case 'php': - return $this->getPhpRequiredList($requiredOnly, $additionalData); - - case 'database': - return $this->getDatabaseRequiredList($requiredOnly, $additionalData); - - case 'permission': - return $this->getRequiredPermissionList($requiredOnly, $additionalData); - } - - return []; + return match ($type) { + 'php' => $this->getPhpRequiredList($requiredOnly), + 'database' => $this->getDatabaseRequiredList($requiredOnly, $additionalData), + 'permission' => $this->getRequiredPermissionList(), + default => [], + }; } /** * Get required PHP params. * - * @param ?array $additionalData - * @return array> + * @return array> */ - protected function getPhpRequiredList(bool $requiredOnly, ?array $additionalData = null): array + public function getPhpRequiredList(bool $requiredOnly): array { $requiredList = [ 'requiredPhpVersion', @@ -107,26 +100,25 @@ class SystemRequirements ]); } - return $this->getRequiredList('phpRequirements', $requiredList, $additionalData); + return $this->getRequiredList('phpRequirements', $requiredList); } /** * Get required DB params. * - * @param ?array $additionalData - * @return array> + * @param ?array $additionalData + * @return array> */ - protected function getDatabaseRequiredList(bool $requiredOnly, ?array $additionalData = null): array + private function getDatabaseRequiredList(bool $requiredOnly, ?array $additionalData = null): array { - $databaseHelper = $this->databaseHelper; - $databaseParams = $additionalData['database'] ?? []; + $databaseParams = $this->databaseParamsFactory + ->createWithMergedAssoc($additionalData['databaseParams'] ?? []); - $pdoConnection = $databaseHelper->createPdoConnection($databaseParams); + $pdo = $this->databaseHelper->createPDO($databaseParams); - $databaseHelper->setPdoConnection($pdoConnection); - $databaseType = $databaseHelper->getDatabaseType(); - $databaseTypeName = ucfirst(strtolower($databaseType)); + $this->databaseHelper = $this->databaseHelper->withPDO($pdo); + $databaseTypeName = ucfirst(strtolower($this->databaseHelper->getType())); $requiredList = [ 'required' . $databaseTypeName . 'Version', @@ -145,17 +137,14 @@ class SystemRequirements /** * Get permission requirements. * - * @param ?array $additionalData - * @return array> + * @return array> */ - private function getRequiredPermissionList(bool $requiredOnly, ?array $additionalData = null): array + private function getRequiredPermissionList(): array { return $this->getRequiredList( 'permissionRequirements', - [ - 'permissionMap.writable', - ], - $additionalData, + ['permissionMap.writable'], + null, [ 'permissionMap.writable' => $this->fileManager->getPermissionUtils()->getWritableList(), ] @@ -164,9 +153,9 @@ class SystemRequirements /** * @param string[] $checkList - * @param ?array $additionalData - * @param array $predefinedData - * @return array> + * @param ?array $additionalData + * @param array $predefinedData + * @return array> */ private function getRequiredList( string $type, @@ -178,30 +167,37 @@ class SystemRequirements $list = []; foreach ($checkList as $itemName) { - $methodName = 'check' . ucfirst($type); + $type = lcfirst($type); - if (method_exists($this, $methodName)) { - $itemValue = - isset($predefinedData[$itemName]) ? - $predefinedData[$itemName] : - $this->config->get($itemName); + $itemValue = $predefinedData[$itemName] ?? $this->config->get($itemName); - $result = $this->$methodName($itemName, $itemValue, $additionalData); - $list = array_merge($list, $result); + $result = []; + + if ($type === 'phpRequirements') { + $result = $this->checkPhpRequirements($itemName, $itemValue); } + + if ($type === 'databaseRequirements') { + $result = $this->checkDatabaseRequirements($itemName, $itemValue, $additionalData); + } + + if ($type === 'permissionRequirements') { + $result = $this->checkPermissionRequirements($itemName, $itemValue); + } + + $list = array_merge($list, $result); } return $list; } /** - * Check PHP requirements, + * Check PHP requirements. * - * @param array|string $data - * @param ?array $additionalData - * @return array> + * @param array|string $data + * @return array> */ - private function checkPhpRequirements(string $type, $data, ?array $additionalData = null): array + private function checkPhpRequirements(string $type, $data): array { $list = []; @@ -247,9 +243,7 @@ class SystemRequirements $requiredValue = $value; $actualValue = $this->systemHelper->getPhpParam($name) ?: '0'; - $acceptable = ( - Util::convertToByte($actualValue) >= Util::convertToByte($requiredValue) - ) ? true : false; + $acceptable = Util::convertToByte($actualValue) >= Util::convertToByte($requiredValue); $list[$name] = [ 'type' => 'param', @@ -268,9 +262,9 @@ class SystemRequirements /** * Check DB requirements. * - * @param array|string $data - * @param ?array $additionalData - * @return array> + * @param array|string $data + * @param ?array $additionalData + * @return array> */ private function checkDatabaseRequirements(string $type, $data, ?array $additionalData = null): array { @@ -278,14 +272,14 @@ class SystemRequirements $databaseHelper = $this->databaseHelper; - $databaseParams = $additionalData['database'] ?? []; + $databaseParams = $additionalData['databaseParams'] ?? []; switch ($type) { case 'requiredMysqlVersion': case 'requiredMariadbVersion': /** @var string $data */ - $actualVersion = $databaseHelper->getDatabaseServerVersion(); + $actualVersion = $databaseHelper->getServerVersion(); $requiredVersion = $data; @@ -309,7 +303,7 @@ class SystemRequirements foreach ($data as $name => $value) { $requiredValue = $value; - $actualValue = $databaseHelper->getDatabaseParam($name); + $actualValue = $databaseHelper->getParam($name); $acceptable = false; @@ -344,21 +338,21 @@ class SystemRequirements $databaseParams = $this->config->get('database'); } - $acceptable = true; - $list['host'] = [ 'type' => 'connection', - 'acceptable' => $acceptable, + 'acceptable' => true, 'actual' => $databaseParams['host'], ]; + $list['dbname'] = [ 'type' => 'connection', - 'acceptable' => $acceptable, + 'acceptable' => true, 'actual' => $databaseParams['dbname'], ]; + $list['user'] = [ 'type' => 'connection', - 'acceptable' => $acceptable, + 'acceptable' => true, 'actual' => $databaseParams['user'], ]; @@ -369,11 +363,10 @@ class SystemRequirements } /** - * @param array $data - * @param ?array $additionalData - * @return array> + * @param array $data + * @return array> */ - private function checkPermissionRequirements(string $type, $data, ?array $additionalData = null): array + private function checkPermissionRequirements(string $type, $data): array { $list = []; @@ -383,21 +376,25 @@ class SystemRequirements case 'permissionMap.writable': foreach ($data as $item) { $fullPathItem = Util::concatPath($this->systemHelper->getRootDir(), $item); + $list[$fullPathItem] = [ 'type' => 'writable', - 'acceptable' => $fileManager->isWritable($fullPathItem) ? true : false, + 'acceptable' => $fileManager->isWritable($fullPathItem), ]; } + break; case 'permissionMap.readable': foreach ($data as $item) { $fullPathItem = Util::concatPath($this->systemHelper->getRootDir(), $item); + $list[$fullPathItem] = [ 'type' => 'readable', - 'acceptable' => $fileManager->isReadable($fullPathItem) ? true : false, + 'acceptable' => $fileManager->isReadable($fullPathItem), ]; } + break; } diff --git a/application/Espo/Modules/Crm/Tools/Activities/Service.php b/application/Espo/Modules/Crm/Tools/Activities/Service.php index 9d75d316f1..15aae25eba 100644 --- a/application/Espo/Modules/Crm/Tools/Activities/Service.php +++ b/application/Espo/Modules/Crm/Tools/Activities/Service.php @@ -161,7 +161,7 @@ class Service 'parentId', 'status', 'createdAt', - ['""', 'hasAttachment'], + ['null', 'hasAttachment'], ]) ->leftJoin( 'MeetingUser', @@ -217,11 +217,11 @@ class Service ['dateEnd', 'dateEnd'], ( $seed->hasAttribute('dateStartDate') ? - ['dateStartDate', 'dateStartDate'] : ['""', 'dateStartDate'] + ['dateStartDate', 'dateStartDate'] : ['null', 'dateStartDate'] ), ( $seed->hasAttribute('dateEndDate') ? - ['dateEndDate', 'dateEndDate'] : ['""', 'dateEndDate'] + ['dateEndDate', 'dateEndDate'] : ['null', 'dateEndDate'] ), ['"Call"', '_scope'], 'assignedUserId', @@ -230,7 +230,7 @@ class Service 'parentId', 'status', 'createdAt', - ['""', 'hasAttachment'], + ['null', 'hasAttachment'], ]) ->leftJoin( 'CallUser', @@ -290,9 +290,9 @@ class Service 'id', 'name', ['dateSent', 'dateStart'], - ['""', 'dateEnd'], - ['""', 'dateStartDate'], - ['""', 'dateEndDate'], + ['null', 'dateEnd'], + ['null', 'dateStartDate'], + ['null', 'dateEndDate'], ['"Email"', '_scope'], 'assignedUserId', 'assignedUserName', @@ -356,11 +356,11 @@ class Service ['dateEnd', 'dateEnd'], ( $seed->hasAttribute('dateStartDate') ? - ['dateStartDate', 'dateStartDate'] : ['""', 'dateStartDate'] + ['dateStartDate', 'dateStartDate'] : ['null', 'dateStartDate'] ), ( $seed->hasAttribute('dateEndDate') ? - ['dateEndDate', 'dateEndDate'] : ['""', 'dateEndDate'] + ['dateEndDate', 'dateEndDate'] : ['null', 'dateEndDate'] ), ['"' . $targetEntityType . '"', '_scope'], 'assignedUserId', @@ -369,7 +369,7 @@ class Service 'parentId', 'status', 'createdAt', - ['""', 'hasAttachment'], + ['false', 'hasAttachment'], ]); if (!empty($statusList)) { @@ -501,9 +501,9 @@ class Service 'id', 'name', ['dateSent', 'dateStart'], - ['""', 'dateEnd'], - ['""', 'dateStartDate'], - ['""', 'dateEndDate'], + ['null', 'dateEnd'], + ['null', 'dateStartDate'], + ['null', 'dateEndDate'], ['"Email"', '_scope'], 'assignedUserId', 'assignedUserName', @@ -1091,22 +1091,22 @@ class Service ->select([ 'id', 'name', - ($seed->hasAttribute('dateStart') ? ['dateStart', 'dateStart'] : ['""', 'dateStart']), - ($seed->hasAttribute('dateEnd') ? ['dateEnd', 'dateEnd'] : ['""', 'dateEnd']), + ($seed->hasAttribute('dateStart') ? ['dateStart', 'dateStart'] : ['null', 'dateStart']), + ($seed->hasAttribute('dateEnd') ? ['dateEnd', 'dateEnd'] : ['null', 'dateEnd']), ($seed->hasAttribute('dateStartDate') ? - ['dateStartDate', 'dateStartDate'] : ['""', 'dateStartDate']), + ['dateStartDate', 'dateStartDate'] : ['null', 'dateStartDate']), ($seed->hasAttribute('dateEndDate') ? - ['dateEndDate', 'dateEndDate'] : ['""', 'dateEndDate']), + ['dateEndDate', 'dateEndDate'] : ['null', 'dateEndDate']), ['"' . $scope . '"', '_scope'], ($seed->hasAttribute('assignedUserId') ? - ['assignedUserId', 'assignedUserId'] : ['""', 'assignedUserId']), + ['assignedUserId', 'assignedUserId'] : ['null', 'assignedUserId']), ($seed->hasAttribute('assignedUserName') ? ['assignedUserName', 'assignedUserName'] : - ['""', 'assignedUserName']), - ($seed->hasAttribute('parentType') ? ['parentType', 'parentType'] : ['""', 'parentType']), - ($seed->hasAttribute('parentId') ? ['parentId', 'parentId'] : ['""', 'parentId']), + ['null', 'assignedUserName']), + ($seed->hasAttribute('parentType') ? ['parentType', 'parentType'] : ['null', 'parentType']), + ($seed->hasAttribute('parentId') ? ['parentId', 'parentId'] : ['null', 'parentId']), 'status', 'createdAt', - ['""', 'hasAttachment'], + ['false', 'hasAttachment'], ]); if ($entity->getEntityType() === User::ENTITY_TYPE) { diff --git a/application/Espo/Modules/Crm/Tools/Calendar/Service.php b/application/Espo/Modules/Crm/Tools/Calendar/Service.php index 4433e8c957..4b4b404fc1 100644 --- a/application/Espo/Modules/Crm/Tools/Calendar/Service.php +++ b/application/Espo/Modules/Crm/Tools/Calendar/Service.php @@ -258,11 +258,11 @@ class Service 'name', ['dateStart', 'dateStart'], ['dateEnd', 'dateEnd'], - ($seed->hasAttribute('status') ? ['status', 'status'] : ['""', 'status']), - ($seed->hasAttribute('dateStartDate') ? ['dateStartDate', 'dateStartDate'] : ['""', 'dateStartDate']), - ($seed->hasAttribute('dateEndDate') ? ['dateEndDate', 'dateEndDate'] : ['""', 'dateEndDate']), - ($seed->hasAttribute('parentType') ? ['parentType', 'parentType'] : ['""', 'parentType']), - ($seed->hasAttribute('parentId') ? ['parentId', 'parentId'] : ['""', 'parentId']), + ($seed->hasAttribute('status') ? ['status', 'status'] : ['null', 'status']), + ($seed->hasAttribute('dateStartDate') ? ['dateStartDate', 'dateStartDate'] : ['null', 'dateStartDate']), + ($seed->hasAttribute('dateEndDate') ? ['dateEndDate', 'dateEndDate'] : ['null', 'dateEndDate']), + ($seed->hasAttribute('parentType') ? ['parentType', 'parentType'] : ['null', 'parentType']), + ($seed->hasAttribute('parentId') ? ['parentId', 'parentId'] : ['null', 'parentId']), 'createdAt', ]; @@ -273,7 +273,7 @@ class Service foreach ($additionalAttributeList as $attribute) { $select[] = $seed->hasAttribute($attribute) ? [$attribute, $attribute] : - ['""', $attribute]; + ['null', $attribute]; } $orGroup = [ @@ -369,7 +369,7 @@ class Service foreach ($additionalAttributeList as $attribute) { $select[] = $seed->hasAttribute($attribute) ? [$attribute, $attribute] : - ['""', $attribute]; + ['null', $attribute]; } return $builder @@ -414,8 +414,8 @@ class Service ['dateStart', 'dateStart'], ['dateEnd', 'dateEnd'], 'status', - ['""', 'dateStartDate'], - ['""', 'dateEndDate'], + ['null', 'dateStartDate'], + ['null', 'dateEndDate'], 'parentType', 'parentId', 'createdAt', @@ -430,7 +430,7 @@ class Service foreach ($additionalAttributeList as $attribute) { $select[] = $seed->hasAttribute($attribute) ? [$attribute, $attribute] : - ['""', $attribute]; + ['null', $attribute]; } return $builder @@ -491,7 +491,7 @@ class Service foreach ($additionalAttributeList as $attribute) { $select[] = $seed->hasAttribute($attribute) ? [$attribute, $attribute] : - ['""', $attribute]; + ['null', $attribute]; } $queryBuilder = $builder diff --git a/application/Espo/ORM/Defs/RelationDefs.php b/application/Espo/ORM/Defs/RelationDefs.php index abf522da1b..94d485aca1 100644 --- a/application/Espo/ORM/Defs/RelationDefs.php +++ b/application/Espo/ORM/Defs/RelationDefs.php @@ -293,6 +293,27 @@ class RelationDefs return $this->data['relationName']; } + /** + * Get indexes. + * + * @return IndexDefs[] + * @throws RuntimeException + */ + public function getIndexList(): array + { + if ($this->getType() !== Entity::MANY_MANY) { + throw new RuntimeException("Can't get indexes."); + } + + $list = []; + + foreach (($this->data['indexes'] ?? []) as $name => $item) { + $list[] = IndexDefs::fromRaw($item, $name); + } + + return $list; + } + /** * Whether a parameter is set. */ diff --git a/application/Espo/Resources/metadata/app/containerServices.json b/application/Espo/Resources/metadata/app/containerServices.json index 37d6557799..d40a734722 100644 --- a/application/Espo/Resources/metadata/app/containerServices.json +++ b/application/Espo/Resources/metadata/app/containerServices.json @@ -2,9 +2,6 @@ "authTokenManager": { "className": "Espo\\Core\\Authentication\\AuthToken\\EspoManager" }, - "schema": { - "className": "Espo\\Core\\Utils\\Database\\Schema\\Schema" - }, "ormMetadataData": { "className": "Espo\\Core\\Utils\\Metadata\\OrmMetadataData" }, @@ -101,4 +98,4 @@ "user": { "settable": true } -} \ No newline at end of file +} diff --git a/application/Espo/Resources/metadata/app/database.json b/application/Espo/Resources/metadata/app/database.json index ad0aac1d04..eeb55bf024 100644 --- a/application/Espo/Resources/metadata/app/database.json +++ b/application/Espo/Resources/metadata/app/database.json @@ -1,6 +1,17 @@ { - "dbalConnectionFactoryClassNameMap": { - "Mysql": "Espo\\Core\\Utils\\Database\\DBAL\\Factories\\MysqlConnectionFactory", - "Postgresql": "Espo\\Core\\Utils\\Database\\DBAL\\Factories\\PostgresqlConnectionFactory" + "platforms": { + "Mysql": { + "detailsProviderClassName": "Espo\\Core\\Utils\\Database\\DetailsProviders\\MysqlDetailsProvider", + "dbalConnectionFactoryClassName": "Espo\\Core\\Utils\\Database\\DBAL\\Factories\\MysqlConnectionFactory", + "indexHelperClassName": "Espo\\Core\\Utils\\Database\\Orm\\IndexHelpers\\MysqlIndexHelper", + "columnPreparatorClassName": "Espo\\Core\\Utils\\Database\\Schema\\ColumnPreparators\\MysqlColumnPreparator", + "preRebuildActionClassNameList": [ + "Espo\\Core\\Utils\\Database\\Schema\\RebuildActions\\PrepareForFulltextIndex" + ], + "postRebuildActionClassNameList": [] + }, + "Postgresql": { + "dbalConnectionFactoryClassName": "Espo\\Core\\Utils\\Database\\DBAL\\Factories\\PostgresqlConnectionFactory" + } } } diff --git a/application/Espo/Resources/metadata/app/rebuild.json b/application/Espo/Resources/metadata/app/rebuild.json index 0094ddc365..720f2acde5 100644 --- a/application/Espo/Resources/metadata/app/rebuild.json +++ b/application/Espo/Resources/metadata/app/rebuild.json @@ -1,5 +1,7 @@ { "actionClassNameList": [ + "Espo\\Core\\Rebuild\\Actions\\AddSystemUser", + "Espo\\Core\\Rebuild\\Actions\\CurrencyRates", "Espo\\Core\\Rebuild\\Actions\\ScheduledJobs", "Espo\\Core\\Rebuild\\Actions\\ConfigMetadataCheck" ] diff --git a/application/Espo/Services/MysqlCharacter.php b/application/Espo/Services/MysqlCharacter.php deleted file mode 100644 index 11957ba012..0000000000 --- a/application/Espo/Services/MysqlCharacter.php +++ /dev/null @@ -1,161 +0,0 @@ -addDependency('container'); - } - - protected function getContainer() /** @phpstan-ignore-line */ - { - return $this->getInjection('container'); - } - - public function jobConvertToMb4() /** @phpstan-ignore-line */ - { - $container = $this->getContainer(); - - - /* @var $em \Espo\ORM\EntityManager */ - $em = $container->get('entityManager'); - - $sqlExecutor = $em->getSqlExecutor(); - - $ormMeta = $container->get('ormMetadata')->getData(true); - - $databaseSchema = $container->get('schema'); - $maxIndexLength = $databaseSchema->getDatabaseHelper()->getMaxIndexLength(); - - if ($maxIndexLength > 1000) { - $maxIndexLength = 1000; - } - - $sth = $sqlExecutor->execute("SELECT `name` FROM `account` WHERE LENGTH(name) > 249"); - - if (!$sth->fetch()) { - $sqlExecutor->execute("ALTER TABLE `account` MODIFY `name` VARCHAR(249)"); - } - - $fieldListExceededIndexMaxLength = SchemaUtils::getFieldListExceededIndexMaxLength($ormMeta, $maxIndexLength); - - foreach ($ormMeta as $entityName => $entityParams) { - $tableName = \Espo\Core\Utils\Util::toUnderScore($entityName); - - // Get table columns params - /** @phpstan-ignore-next-line */ - $query = "SHOW FULL COLUMNS FROM `". $tableName ."` WHERE `Collation` <> 'utf8mb4_unicode_ci'"; - - try { - $sth = $sqlExecutor->execute($query); - } - catch (\Exception $e) { - $GLOBALS['log']->debug('Utf8mb4: Table does not exist - ' . $e->getMessage()); - - continue; - } - - $columnParams = []; - - $rowList = $sth->fetchAll(); - - foreach ($rowList as $row) { - $columnParams[ $row['Field'] ] = $row; - } - // END: get table columns params - - foreach ($entityParams['fields'] as $fieldName => $fieldParams) { - $columnName = \Espo\Core\Utils\Util::toUnderScore($fieldName); - - if (isset($fieldParams['notStorable']) && $fieldParams['notStorable']) { - continue; - } - - if (isset($fieldListExceededIndexMaxLength[$entityName]) && in_array($fieldName, $fieldListExceededIndexMaxLength[$entityName])) { - continue; - } - - /** @phpstan-ignore-next-line */ - if (!isset($columnParams[$columnName]) || empty($columnParams[$columnName]['Type'])) { - continue; - } - - $query = null; - - switch ($fieldParams['type']) { - case 'varchar': - case 'text': - case 'jsonObject': - case 'jsonArray': - /** @phpstan-ignore-next-line */ - $query = "ALTER TABLE `".$tableName."` ". - /** @phpstan-ignore-next-line */ - "CHANGE COLUMN `". $columnName ."` `". $columnName ."` ". $columnParams[$columnName]['Type'] . - /** @phpstan-ignore-next-line */ - " CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"; - - break; - } - - if (!empty($query)) { - $GLOBALS['log']->debug('Utf8mb4: execute the query - [' . $query . '].'); - - try { - $sqlExecutor->execute($query); - } - catch (\Exception $e) { - $GLOBALS['log']->warning( - 'Utf8mb4: FAILED executing the query - [' . $query . '], details: '. $e->getMessage() .'.' - ); - } - } - } - } - - $config = $container->get('config'); - $database = $config->get('database'); - - if (!isset($database['charset']) || $database['charset'] != 'utf8mb4') { - $database['charset'] = 'utf8mb4'; - $config->set('database', $database); - $config->save(); - } - - $this->getContainer()->get('dataManager')->rebuild(); - } -} diff --git a/application/Espo/Tools/Currency/RateService.php b/application/Espo/Tools/Currency/RateService.php index 21cb12ea6f..a165baf9da 100644 --- a/application/Espo/Tools/Currency/RateService.php +++ b/application/Espo/Tools/Currency/RateService.php @@ -30,13 +30,11 @@ namespace Espo\Tools\Currency; use Espo\Core\Exceptions\BadRequest; -use Espo\Core\Exceptions\Error; use Espo\Core\Exceptions\Forbidden; use Espo\Core\Acl; -use Espo\Core\DataManager; use Espo\Core\Utils\Config; use Espo\Core\Utils\Config\ConfigWriter; - +use Espo\Core\Utils\Currency\DatabasePopulator; use stdClass; class RateService @@ -46,8 +44,8 @@ class RateService public function __construct( private Config $config, private ConfigWriter $configWriter, - private DataManager $dataManager, - private Acl $acl + private Acl $acl, + private DatabasePopulator $databasePopulator ) {} /** @@ -71,7 +69,6 @@ class RateService /** * @throws BadRequest * @throws Forbidden - * @throws Error */ public function set(stdClass $rates): stdClass { @@ -114,12 +111,10 @@ class RateService } $this->configWriter->set('currencyRates', $rates); - $this->configWriter->save(); - $this->dataManager->rebuildDatabase([]); - return (object) ( - $config->get('currencyRates') ?? [] - ); + $this->databasePopulator->process(); + + return (object) ($config->get('currencyRates') ?? []); } } diff --git a/install/core/Installer.php b/install/core/Installer.php index 46a0b300f5..ef8c907bcf 100644 --- a/install/core/Installer.php +++ b/install/core/Installer.php @@ -31,6 +31,7 @@ use Espo\Core\Application; use Espo\Core\Container; use Espo\Core\InjectableFactory; +use Espo\Core\ORM\DatabaseParamsFactory; use Espo\Core\Utils\Util; use Espo\Core\Utils\Config\ConfigFileManager; use Espo\Core\Utils\Config; @@ -57,6 +58,7 @@ class Installer private $isAuth = false; private $passwordHash; private $defaultSettings; + private DatabaseParamsFactory $databaseParamsFactory; private $permittedSettingList = [ 'dateFormat', @@ -92,6 +94,7 @@ class Installer $this->systemHelper = new SystemHelper(); $this->databaseHelper = $this->getInjectableFactory()->create(DatabaseHelper::class); + $this->databaseParamsFactory = $this->getInjectableFactory()->create(DatabaseParamsFactory::class); } private function initialize(): void @@ -297,6 +300,7 @@ class Installer public function getSystemRequirementList($type, $requiredOnly = false, array $additionalData = null) { + /** @var SystemRequirements $systemRequirementManager */ $systemRequirementManager = $this->app ->getContainer() ->get('injectableFactory') @@ -309,13 +313,14 @@ class Installer array $params, bool $createDatabase = false ) { + $databaseParams = $this->databaseParamsFactory->createWithMergedAssoc($params); + try { - $pdo = $this->getDatabaseHelper()->createPdoConnection($params); + $this->getDatabaseHelper()->createPDO($databaseParams); } catch (Exception $e) { if ($createDatabase && $e->getCode() == '1049') { - $pdo = $this->getDatabaseHelper() - ->createPdoConnection($params, true); + $pdo = $this->getDatabaseHelper()->createPDO($databaseParams, true); $dbname = preg_replace('/[^A-Za-z0-9_\-@$#\(\)]+/', '', $params['dbname']); diff --git a/install/core/actions/settingsTest.php b/install/core/actions/settingsTest.php index ad97b04669..e7b1e58e17 100644 --- a/install/core/actions/settingsTest.php +++ b/install/core/actions/settingsTest.php @@ -83,7 +83,8 @@ if ($result['success'] && !empty($allPostData['dbName']) && !empty($allPostData[ } if ($isConnected) { - $databaseRequiredList = $installer->getSystemRequirementList('database', true, ['database' => $databaseParams]); + $databaseRequiredList = $installer + ->getSystemRequirementList('database', true, ['databaseParams' => $databaseParams]); foreach ($databaseRequiredList as $name => $details) { if (!$details['acceptable']) { diff --git a/install/core/actions/setupConfirmation.php b/install/core/actions/setupConfirmation.php index 5bdd0e9e6b..f6f3cdfd46 100644 --- a/install/core/actions/setupConfirmation.php +++ b/install/core/actions/setupConfirmation.php @@ -33,15 +33,15 @@ $smarty->assign('phpRequirementList', $phpRequirementList); $installData = $_SESSION['install']; $hostData = explode(':', $installData['host-name']); -$dbConfig = array( - 'host' => isset($hostData[0]) ? $hostData[0] : '', - 'port' => isset($hostData[1]) ? $hostData[1] : '', +$dbConfig = [ + 'host' => $hostData[0] ?? '', + 'port' => $hostData[1] ?? '', 'dbname' => $installData['db-name'], 'user' => $installData['db-user-name'], 'password' => $installData['db-user-password'], -); +]; -$mysqlRequirementList = $installer->getSystemRequirementList('database', false, ['database' => $dbConfig]); +$mysqlRequirementList = $installer->getSystemRequirementList('database', false, ['databaseParams' => $dbConfig]); $smarty->assign('mysqlRequirementList', $mysqlRequirementList); $permissionRequirementList = $installer->getSystemRequirementList('permission'); diff --git a/tests/integration/Espo/Core/Utils/Database/VarcharFieldTest.php b/tests/integration/Espo/Core/Utils/Database/VarcharFieldTest.php index b7f67498fb..eb02fb88a2 100644 --- a/tests/integration/Espo/Core/Utils/Database/VarcharFieldTest.php +++ b/tests/integration/Espo/Core/Utils/Database/VarcharFieldTest.php @@ -202,8 +202,8 @@ class VarcharFieldTest extends Base $dbHelper = $this->getInjectableFactory()->create(DatabaseHelper::class); if ( - $dbHelper->getDatabaseType() == 'MariaDB' - && version_compare($dbHelper->getDatabaseVersion(), '10.2.7', '>=') + $dbHelper->getType() == 'MariaDB' + && version_compare($dbHelper->getVersion(), '10.2.7', '>=') ) { $this->assertEquals("'test-default'", $column['COLUMN_DEFAULT']); } else { diff --git a/tests/unit/Espo/Core/ORM/DatabaseParamsFactoryTest.php b/tests/unit/Espo/Core/ORM/DatabaseParamsFactoryTest.php new file mode 100644 index 0000000000..da71b00e51 --- /dev/null +++ b/tests/unit/Espo/Core/ORM/DatabaseParamsFactoryTest.php @@ -0,0 +1,89 @@ +createConfig()); + + $params = $factory->create(); + + $this->assertEquals('test:host', $params->getHost()); + $this->assertEquals(10, $params->getPort()); + $this->assertEquals('name-db', $params->getName()); + $this->assertEquals('test-user', $params->getUsername()); + $this->assertEquals('test-password', $params->getPassword()); + $this->assertEquals('test-platform', $params->getPlatform()); + } + + public function testCreateWithMergedAssoc(): void + { + $factory = new DatabaseParamsFactory($this->createConfig()); + + $params = $factory->createWithMergedAssoc([ + 'host' => 'test:host2', + 'port' => 11, + 'dbname' => 'name2-db', + 'user' => 'test-user2', + 'password' => 'test-password2', + ]); + + $this->assertEquals('test:host2', $params->getHost()); + $this->assertEquals(11, $params->getPort()); + $this->assertEquals('name2-db', $params->getName()); + $this->assertEquals('test-user2', $params->getUsername()); + $this->assertEquals('test-password2', $params->getPassword()); + $this->assertEquals('test-platform', $params->getPlatform()); + } + + private function createConfig(): Config + { + $config = $this->createMock(Config::class); + + $config->expects($this->any()) + ->method('get') + ->willReturnMap([ + ['database', null, ['d' => 'd']], + ['database.host', null, 'test:host'], + ['database.port', null, 10], + ['database.dbname', null, 'name-db'], + ['database.user', null, 'test-user'], + ['database.password', null, 'test-password'], + ['database.platform', null, 'test-platform'], + ]); + + return $config; + } +} diff --git a/upgrades/7.3/scripts/BeforeUpgrade.php b/upgrades/7.3/scripts/BeforeUpgrade.php index 73105d65ad..06063982b1 100644 --- a/upgrades/7.3/scripts/BeforeUpgrade.php +++ b/upgrades/7.3/scripts/BeforeUpgrade.php @@ -129,7 +129,7 @@ class BeforeUpgrade { $databaseHelper = new DatabaseHelper($config); - $pdo = $databaseHelper->createPdoConnection(); + $pdo = $databaseHelper->getDatabaseType(); $query = " ALTER TABLE `user` ADD `working_time_calendar_id` VARCHAR(24)