Initial commit

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

View File

@@ -0,0 +1,92 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils\Acl;
use Espo\Core\Acl\Exceptions\NotAvailable;
use Espo\Entities\Portal;
use Espo\Entities\User;
use Espo\ORM\EntityManager;
use Espo\Core\AclManager;
use Espo\Core\Portal\AclManagerContainer as PortalAclManagerContainer;
use Espo\Core\ApplicationState;
/**
* @todo Use WeakMap (User as a key).
*/
class UserAclManagerProvider
{
/** @var array<string, AclManager> */
private $map = [];
public function __construct(
private EntityManager $entityManager,
private AclManager $aclManager,
private PortalAclManagerContainer $portalAclManagerContainer,
private ApplicationState $applicationState
) {}
/**
* @throws NotAvailable
*/
public function get(User $user): AclManager
{
$key = $user->hasId() ? $user->getId() : spl_object_hash($user);
if (!isset($this->map[$key])) {
$this->map[$key] = $this->load($user);
}
return $this->map[$key];
}
/**
* @throws NotAvailable
*/
private function load(User $user): AclManager
{
$aclManager = $this->aclManager;
if ($user->isPortal() && !$this->applicationState->isPortal()) {
/** @var ?Portal $portal */
$portal = $this->entityManager
->getRDBRepository(User::ENTITY_TYPE)
->getRelation($user, 'portals')
->findOne();
if (!$portal) {
throw new NotAvailable("No portal for portal user '" . $user->getId() . "'.");
}
$aclManager = $this->portalAclManagerContainer->get($portal);
}
return $aclManager;
}
}

View File

@@ -0,0 +1,118 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils\Address;
use Espo\Core\Name\Field;
use Espo\Core\Utils\Config;
use Espo\Core\Utils\DataCache;
use Espo\Entities\AddressCountry;
use Espo\ORM\EntityManager;
use Espo\ORM\Query\Part\Order;
class CountryDataProvider
{
/** @var ?array{list: string[], preferredList: string[]} */
private ?array $data = null;
private bool $useCache;
private const CACHE_KEY = 'addressCountryData';
private const LIMIT = 500;
public function __construct(
private DataCache $dataCache,
private EntityManager $entityManager,
Config\SystemConfig $systemConfig,
) {
$this->useCache = $systemConfig->useCache();
}
/**
* @return array{list: string[], preferredList: string[]}
*/
public function get(): array
{
if ($this->data === null) {
$this->data = $this->load();
}
return $this->data;
}
/**
* @return array{list: string[], preferredList: string[]}
*/
private function load(): array
{
if ($this->useCache && $this->dataCache->has(self::CACHE_KEY)) {
$list = $this->dataCache->get(self::CACHE_KEY);
if (
is_array($list) &&
is_array($list['list'] ?? null) &&
is_array($list['preferredList'] ?? null)
) {
/** @var array{list: string[], preferredList: string[]} */
return $list;
}
}
$list = [];
$preferredList = [];
/** @var iterable<AddressCountry> $collection */
$collection = $this->entityManager
->getRDBRepositoryByClass(AddressCountry::class)
->sth()
->select([Field::NAME, 'isPreferred'])
->order(Field::NAME, Order::ASC)
->limit(0, self::LIMIT)
->find();
foreach ($collection as $entity) {
$list[] = $entity->getName();
if ($entity->isPreferred()) {
$preferredList[] = $entity->getName();
}
}
if ($this->useCache) {
$this->dataCache->store(self::CACHE_KEY, [
'list' => $list,
'preferredList' => $preferredList,
]);
}
return [
'list' => $list,
'preferredList' => $preferredList,
];
}
}

View File

@@ -0,0 +1,101 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils;
use Espo\Core\Utils\Config\ConfigWriter;
class ApiKey
{
public function __construct(
private Config $config,
private ConfigWriter $configWriter)
{}
public static function hash(string $secretKey, string $string = ''): string
{
return hash_hmac('sha256', $string, $secretKey);
}
/**
* @deprecated
* @internal
*/
public static function hashLegacy(string $secretKey, string $string = ''): string
{
return hash_hmac('sha256', $string, $secretKey, true);
}
public function getSecretKeyForUserId(string $id): ?string
{
$apiSecretKeys = $this->config->get('apiSecretKeys');
if (!$apiSecretKeys) {
return null;
}
if (!is_object($apiSecretKeys)) {
return null;
}
if (!isset($apiSecretKeys->$id)) {
return null;
}
return $apiSecretKeys->$id;
}
public function storeSecretKeyForUserId(string $id, string $secretKey): void
{
$apiSecretKeys = $this->config->get('apiSecretKeys');
if (!is_object($apiSecretKeys)) {
$apiSecretKeys = (object) [];
}
$apiSecretKeys->$id = $secretKey;
$this->configWriter->set('apiSecretKeys', $apiSecretKeys);
$this->configWriter->save();
}
public function removeSecretKeyForUserId(string $id): void
{
$apiSecretKeys = $this->config->get('apiSecretKeys');
if (!is_object($apiSecretKeys)) {
$apiSecretKeys = (object) [];
}
unset($apiSecretKeys->$id);
$this->configWriter->set('apiSecretKeys', $apiSecretKeys);
$this->configWriter->save();
}
}

View File

@@ -0,0 +1,174 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils;
use Espo\Core\Utils\Autoload\Loader;
use Espo\Core\Utils\Config\SystemConfig;
use Espo\Core\Utils\File\Manager as FileManager;
use Espo\Core\Utils\Resource\PathProvider;
use Exception;
class Autoload
{
/** @var ?array<string, mixed> */
private $data = null;
private string $cacheKey = 'autoload';
private string $autoloadFileName = 'autoload.json';
public function __construct(
private Metadata $metadata,
private DataCache $dataCache,
private FileManager $fileManager,
private Loader $loader,
private PathProvider $pathProvider,
private SystemConfig $systemConfig,
) {}
/**
* @return array<string, mixed>
*/
private function getData(): array
{
if (!isset($this->data)) {
$this->init();
}
assert($this->data !== null);
return $this->data;
}
private function init(): void
{
$useCache = $this->systemConfig->useCache();
if ($useCache && $this->dataCache->has($this->cacheKey)) {
/** @var ?array<string, mixed> $data */
$data = $this->dataCache->get($this->cacheKey);
$this->data = $data;
return;
}
$this->data = $this->loadData();
if ($useCache) {
$this->dataCache->store($this->cacheKey, $this->data);
}
}
/**
* @return array<string, mixed>
*/
private function loadData(): array
{
$corePath = $this->pathProvider->getCore() . $this->autoloadFileName;
$data = $this->loadDataFromFile($corePath);
foreach ($this->metadata->getModuleList() as $moduleName) {
$modulePath = $this->pathProvider->getModule($moduleName) . $this->autoloadFileName;
$data = array_merge_recursive(
$data,
$this->loadDataFromFile($modulePath)
);
}
$customPath = $this->pathProvider->getCustom() . $this->autoloadFileName;
return array_merge_recursive(
$data,
$this->loadDataFromFile($customPath)
);
}
/**
* @return array<string, mixed>
* @throws \JsonException
*/
private function loadDataFromFile(string $filePath): array
{
if (!$this->fileManager->isFile($filePath)) {
return [];
}
$content = $this->fileManager->getContents($filePath);
$arrayContent = Json::decode($content, true);
return $this->normalizeData($arrayContent);
}
/**
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
private function normalizeData(array $data): array
{
$normalizedData = [];
foreach ($data as $key => $value) {
switch ($key) {
case 'psr-4':
case 'psr-0':
case 'classmap':
case 'files':
case 'autoloadFileList':
$normalizedData[$key] = $value;
break;
default:
$normalizedData['psr-0'][$key] = $value;
break;
}
}
return $normalizedData;
}
public function register(): void
{
try {
$data = $this->getData();
} catch (Exception) {} // bad permissions
if (empty($data)) {
return;
}
$this->loader->register($data);
}
}

View File

@@ -0,0 +1,98 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils\Autoload;
use Espo\Core\Utils\File\Manager as FileManager;
class Loader
{
public function __construct(
private NamespaceLoader $namespaceLoader,
private FileManager $fileManager
) {}
/**
*
* @param array{
* psr-4?: array<string, mixed>,
* psr-0?: array<string, mixed>,
* classmap?: array<string, mixed>,
* autoloadFileList?: array<string, mixed>,
* files?: array<string, mixed>,
* } $data
*/
public function register(array $data): void
{
/* load "psr-4", "psr-0", "classmap" */
$this->namespaceLoader->register($data);
/* load "autoloadFileList" */
$this->registerAutoloadFileList($data);
/* load "files" */
$this->registerFiles($data);
}
/**
* @param array<string, mixed> $data
*/
private function registerAutoloadFileList(array $data): void
{
$keyName = 'autoloadFileList';
if (!isset($data[$keyName])) {
return;
}
foreach ($data[$keyName] as $filePath) {
if ($this->fileManager->exists($filePath)) {
require_once($filePath);
}
}
}
/**
* @param array<string, mixed> $data
*/
private function registerFiles(array $data): void
{
$keyName = 'files';
if (!isset($data[$keyName])) {
return;
}
foreach ($data[$keyName] as $filePath) {
if ($this->fileManager->exists($filePath)) {
require_once($filePath);
}
}
}
}

View File

@@ -0,0 +1,280 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils\Autoload;
use Espo\Core\Utils\Config\SystemConfig;
use Espo\Core\Utils\DataCache;
use Espo\Core\Utils\File\Manager as FileManager;
use Espo\Core\Utils\Log;
use Espo\Core\Utils\Util;
use Composer\Autoload\ClassLoader;
use Throwable;
class NamespaceLoader
{
/**
* @var ?array{
* psr-4?: array<string, mixed>,
* psr-0?: array<string, mixed>,
* classmap?: array<string, mixed>,
* }
*/
private $namespaces = null;
/** @var ?array<string, mixed> */
private $vendorNamespaces = null;
private string $autoloadFilePath = 'vendor/autoload.php';
/** @var array<'psr-4'|'psr-0'|'classmap', string> */
private $namespacesPaths = [
'psr-4' => 'vendor/composer/autoload_psr4.php',
'psr-0' => 'vendor/composer/autoload_namespaces.php',
'classmap' => 'vendor/composer/autoload_classmap.php',
];
/** @var array<'psr-4'|'psr-0', string> */
private $methodNameMap = [
'psr-4' => 'addPsr4',
'psr-0' => 'add',
];
private string $cacheKey = 'autoloadVendorNamespaces';
private ClassLoader $classLoader;
public function __construct(
private DataCache $dataCache,
private FileManager $fileManager,
private Log $log,
private SystemConfig $systemConfig,
) {
$this->classLoader = new ClassLoader();
}
/**
* @param array{
* psr-4?: array<string, mixed>,
* psr-0?: array<string, mixed>
* } $data
*/
public function register(array $data): void
{
$this->addListToClassLoader($data);
$this->classLoader->register(true);
}
/**
* @return array{
* psr-4?: array<string, mixed>,
* psr-0?: array<string, mixed>,
* classmap?: array<string, mixed>,
* }
*/
private function loadNamespaces(string $basePath = ''): array
{
$namespaces = [];
foreach ($this->namespacesPaths as $type => $path) {
$mapFile = Util::concatPath($basePath, $path);
if (!$this->fileManager->exists($mapFile)) {
continue;
}
$map = require($mapFile);
if (!empty($map) && is_array($map)) {
$namespaces[$type] = $map;
}
}
return $namespaces;
}
/**
*
* @return array{
* psr-4?: array<string, mixed>,
* psr-0?: array<string, mixed>,
* classmap?: array<string, mixed>,
* }
*/
private function getNamespaces(): array
{
if (!$this->namespaces) {
$this->namespaces = $this->loadNamespaces();
}
return $this->namespaces;
}
/**
* @param 'psr-4'|'psr-0'|'classmap' $type
* @return string[]
*/
private function getNamespaceList(string $type): array
{
$namespaces = $this->getNamespaces();
return array_keys($namespaces[$type] ?? []);
}
/**
* @param 'psr-4'|'psr-0'|'classmap' $type
* @param string|array<string, string> $path
*/
private function addNamespace(string $type, string $name, $path): void
{
if (!$this->namespaces) {
$this->getNamespaces();
}
$this->namespaces[$type][$name] = (array) $path;
}
/**
* @param 'psr-4'|'psr-0'|'classmap' $type
*/
private function hasNamespace(string $type, string $name): bool
{
if (in_array($name, $this->getNamespaceList($type))) {
return true;
}
if (!preg_match('/\\\$/', $name)) {
$name = $name . '\\';
if (in_array($name, $this->getNamespaceList($type))) {
return true;
}
}
return false;
}
/**
* @param array<string, mixed> $data
*/
private function addListToClassLoader(array $data, bool $skipVendorNamespaces = false): void
{
foreach ($this->methodNameMap as $type => $methodName) {
$itemData = $data[$type] ?? null;
if ($itemData === null) {
continue;
}
foreach ($itemData as $prefix => $path) {
if (!$skipVendorNamespaces) {
$vendorPaths = is_array($path) ? $path : (array) $path;
foreach ($vendorPaths as $vendorPath) {
$this->addListToClassLoader(
$this->getVendorNamespaces($vendorPath),
true
);
}
}
if ($this->hasNamespace($type, $prefix)) {
continue;
}
try {
$this->classLoader->$methodName($prefix, $path);
} catch (Throwable $e) {
$this->log->error("Could not add '{$prefix}' to autoload: " . $e->getMessage());
continue;
}
$this->addNamespace($type, $prefix, $path);
}
}
$classMap = $data['classmap'] ?? null;
if ($classMap !== null) {
$this->classLoader->addClassMap($classMap);
}
}
/**
* @return array<string, mixed>
*/
private function getVendorNamespaces(string $path): array
{
$useCache = $this->systemConfig->useCache();
if (!isset($this->vendorNamespaces)) {
$this->vendorNamespaces = [];
if ($useCache && $this->dataCache->has($this->cacheKey)) {
/** @var ?array<string, mixed> $cachedData */
$cachedData = $this->dataCache->get($this->cacheKey);
$this->vendorNamespaces = $cachedData;
}
}
assert($this->vendorNamespaces !== null);
if (!array_key_exists($path, $this->vendorNamespaces)) {
$vendorPath = $this->findVendorPath($path);
if ($vendorPath) {
$this->vendorNamespaces[$path] = $this->loadNamespaces($vendorPath);
if ($useCache) {
$this->dataCache->store($this->cacheKey, $this->vendorNamespaces);
}
}
}
return $this->vendorNamespaces[$path] ?? [];
}
private function findVendorPath(string $path): ?string
{
$vendor = Util::concatPath($path, $this->autoloadFilePath);
if ($this->fileManager->exists($vendor)) {
return $path;
}
$parentDir = dirname($path);
if (!empty($parentDir) && $parentDir !== '.') {
return $this->findVendorPath($parentDir);
}
return null;
}
}

View File

@@ -0,0 +1,96 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils;
use Espo\Core\Utils\File\ClassMap;
/**
* Finds classes of a specific category. Category examples: Services, Controllers.
* First it checks in the `custom` folder, then modules, then the internal folder.
* Available as 'classFinder' service.
*/
class ClassFinder
{
/** @var array<string, array<string, class-string>> */
private $dataHashMap = [];
public function __construct(private ClassMap $classMap)
{}
/**
* Reset runtime cache.
*
* @internal
* @since 8.4.0
*/
public function resetRuntimeCache(): void
{
$this->dataHashMap = [];
}
/**
* Find class name by a category and name.
*
* @return ?class-string
*/
public function find(string $category, string $name, bool $subDirs = false): ?string
{
$map = $this->getMap($category, $subDirs);
return $map[$name] ?? null;
}
/**
* Get a name => class name map.
*
* @return array<string, class-string>
*/
public function getMap(string $category, bool $subDirs = false): array
{
if (!array_key_exists($category, $this->dataHashMap)) {
$this->load($category, $subDirs);
}
return $this->dataHashMap[$category] ?? [];
}
private function load(string $category, bool $subDirs = false): void
{
$cacheFile = $this->buildCacheKey($category);
$this->dataHashMap[$category] = $this->classMap->getData($category, $cacheFile, null, $subDirs);
}
private function buildCacheKey(string $category): string
{
return 'classmap' . str_replace('/', '', $category);
}
}

View File

@@ -0,0 +1,106 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils\Client;
use Espo\Core\Api\Response;
use Espo\Core\Utils\Client\ActionRenderer\Params;
use Espo\Core\Utils\Json;
use Espo\Core\Utils\ClientManager;
/**
* Renders a front-end page that executes a controller action. Utilized by entry points.
*/
class ActionRenderer
{
public function __construct(private ClientManager $clientManager)
{}
/**
* Writes to a body.
*/
public function write(Response $response, Params $params): void
{
$body = $this->render(
controller: $params->getController(),
action: $params->getAction(),
data: $params->getData(),
initAuth: $params->initAuth(),
scripts: $params->getScripts(),
pageTitle: $params->getPageTitle(),
theme: $params->getTheme(),
);
$securityParams = new SecurityParams(
frameAncestors: $params->getFrameAncestors(),
);
$this->clientManager->writeHeaders($response, $securityParams);
$response->writeBody($body);
}
/**
* @param ?array<string, mixed> $data
* @param Script[] $scripts
*/
private function render(
string $controller,
string $action,
?array $data,
bool $initAuth,
array $scripts,
?string $pageTitle,
?string $theme,
): string {
$encodedData = Json::encode($data);
$initAuthPart = $initAuth ? "app.initAuth();" : '';
$script =
"
{$initAuthPart}
app.doAction({
controllerClassName: '$controller',
action: '$action',
options: $encodedData,
});
";
$params = new RenderParams(
runScript: $script,
scripts: $scripts,
pageTitle: $pageTitle,
theme: $theme,
);
return $this->clientManager->render($params);
}
}

View File

@@ -0,0 +1,189 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils\Client\ActionRenderer;
use Espo\Core\Utils\Client\Script;
/**
* Immutable.
*/
class Params
{
/** @var ?array<string, mixed> */
private ?array $data;
private bool $initAuth = false;
/** @var string[] */
private array $frameAncestors = [];
/** @var Script[] */
private array $scripts = [];
private ?string $pageTitle = null;
private ?string $theme = null;
/**
* @param ?array<string, mixed> $data
*/
public function __construct(
private string $controller,
private string $action,
?array $data = null
) {
$this->data = $data;
}
/**
* @param ?array<string, mixed> $data
*/
public static function create(string $controller, string $action, ?array $data = null): self
{
return new self($controller, $action, $data);
}
/**
* @param array<string, mixed> $data
*/
public function withData(array $data): self
{
$obj = clone $this;
$obj->data = $data;
return $obj;
}
public function withInitAuth(bool $initAuth = true): self
{
$obj = clone $this;
$obj->initAuth = $initAuth;
return $obj;
}
/**
* @param string[] $frameAncestors
* @since 9.0.0
*/
public function withFrameAncestors(array $frameAncestors): self
{
$obj = clone $this;
$obj->frameAncestors = $frameAncestors;
return $obj;
}
/**
* @param Script[] $scripts
* @since 9.0.0
*/
public function withScripts(array $scripts): self
{
$obj = clone $this;
$obj->scripts = $scripts;
return $obj;
}
/**
* @since 9.1.0
*/
public function withPageTitle(?string $pageTitle): self
{
$obj = clone $this;
$obj->pageTitle = $pageTitle;
return $obj;
}
/**
* @since 9.1.0
*/
public function withTheme(?string $theme): self
{
$obj = clone $this;
$obj->theme = $theme;
return $obj;
}
public function getController(): string
{
return $this->controller;
}
public function getAction(): string
{
return $this->action;
}
/**
* @return ?array<string, mixed>
*/
public function getData(): ?array
{
return $this->data;
}
public function initAuth(): bool
{
return $this->initAuth;
}
/**
* @return string[]
* @since 9.0.0
*/
public function getFrameAncestors(): array
{
return $this->frameAncestors;
}
/**
* @return Script[]
* @since 9.0.0
*/
public function getScripts(): array
{
return $this->scripts;
}
/**
* @since 9.1.0
*/
public function getPageTitle(): ?string
{
return $this->pageTitle;
}
/**
* @since 9.1.0
*/
public function getTheme(): ?string
{
return $this->theme;
}
}

View File

@@ -0,0 +1,91 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils\Client;
use Espo\Core\Utils\Config;
use Espo\Core\Utils\File\Manager as FileManager;
use Espo\Core\Utils\Module;
use Espo\Core\Utils\Util;
/**
* Allows bundled extensions to work when the system is in the developer mode.
*/
class DevModeExtensionInitJsFileListProvider
{
public function __construct(
private Module $module,
private FileManager $fileManager,
private Config $config,
) {}
/**
* @return string[]
*/
public function get(): array
{
$developedModule = $this->config->get('developedModule');
if (!$developedModule) {
return [];
}
$output = [];
foreach ($this->getBundledModuleList() as $module) {
if ($module === $developedModule) {
continue;
}
$file = "client/custom/modules/$module/lib/init.js";
if ($this->fileManager->exists($file)) {
$output[] = $file;
}
}
return $output;
}
/**
* @return string[]
*/
private function getBundledModuleList(): array
{
$modules = array_values(array_filter(
$this->module->getList(),
fn ($item) => $this->module->get([$item, 'bundled'])
));
return array_map(
fn ($item) => Util::fromCamelCase($item, '-'),
$modules
);
}
}

View File

@@ -0,0 +1,109 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils\Client;
use Espo\Core\Utils\File\Manager as FileManager;
use RuntimeException;
/**
* @internal Also used by the installer w/o DI.
*/
class DevModeJsFileListProvider
{
private const LIBS_FILE = 'frontend/libs.json';
public function __construct(private FileManager $fileManager)
{}
/**
* @return string[]
*/
public function get(): array
{
$list = [];
$items = json_decode($this->fileManager->getContents(self::LIBS_FILE));
foreach ($items as $item) {
if (!($item->bundle ?? false)) {
continue;
}
$files = $item->files ?? null;
if ($files !== null) {
$list = array_merge(
$list,
array_map(
fn ($item) => self::prepareBundleLibFilePath($item),
$files
)
);
continue;
}
if (!isset($item->src)) {
continue;
}
$list[] = self::prepareBundleLibFilePath($item);
}
return $list;
}
private function prepareBundleLibFilePath(object $item): string
{
$amdId = $item->amdId ?? null;
if ($amdId) {
$file = $amdId;
if (str_starts_with($amdId, '@')) {
$file = substr($amdId, 1);
$file = str_replace('/', '-', $file);
}
return 'client/lib/original/' . $file . '.js';
}
$src = $item->src ?? null;
if (!$src) {
throw new RuntimeException("Missing 'src' in bundled lib definition.");
}
$arr = explode('/', $src);
return 'client/lib/original/' . array_slice($arr, -1)[0];
}
}

View File

@@ -0,0 +1,67 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils\Client;
use Espo\Core\Utils\Metadata;
class LoaderParamsProvider
{
public function __construct(
private Metadata $metadata
) {}
public function getLibsConfig(): object
{
return (object) $this->metadata->get(['app', 'jsLibs'], []);
}
public function getAliasMap(): object
{
$map = (object) [];
/** @var array<string, array<string, mixed>> $libs */
$libs = $this->metadata->get(['app', 'jsLibs'], []);
foreach ($libs as $name => $item) {
/** @var ?string[] $aliases */
$aliases = $item['aliases'] ?? null;
$map->$name = 'lib!' . $name;
if ($aliases) {
foreach ($aliases as $alias) {
$map->$alias = 'lib!' . $name;
}
}
}
return $map;
}
}

View File

@@ -0,0 +1,46 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils\Client;
readonly class RenderParams
{
/**
* @param ?string $runScript A JS run-script.
* @param Script[] $scripts Scripts to include on the page.
* @param ?string $pageTitle A page title. Since 9.1.0.
* @param ?string $theme A page theme name.
*/
public function __construct(
public ?string $runScript = null,
public array $scripts = [],
public ?string $pageTitle = null,
public ?string $theme = null,
) {}
}

View File

@@ -0,0 +1,40 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils\Client;
class Script
{
public function __construct(
readonly public string $source,
readonly public bool $cacheBusting = false,
readonly public bool $async = false,
readonly public bool $defer = false,
) {}
}

View File

@@ -0,0 +1,40 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils\Client;
class SecurityParams
{
/**
* @param string[] $frameAncestors
*/
public function __construct(
readonly public array $frameAncestors = [],
) {}
}

View File

@@ -0,0 +1,491 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils;
use Espo\Core\Api\Response;
use Espo\Core\Api\ResponseWrapper;
use Espo\Core\Utils\Client\DevModeExtensionInitJsFileListProvider;
use Espo\Core\Utils\Client\DevModeJsFileListProvider;
use Espo\Core\Utils\Client\LoaderParamsProvider;
use Espo\Core\Utils\Client\RenderParams;
use Espo\Core\Utils\Client\Script;
use Espo\Core\Utils\Client\SecurityParams;
use Espo\Core\Utils\Config\ApplicationConfig;
use Espo\Core\Utils\Config\SystemConfig;
use Espo\Core\Utils\File\Manager as FileManager;
use Espo\Core\Utils\Theme\MetadataProvider as ThemeMetadataProvider;
use Slim\Psr7\Response as Psr7Response;
use Slim\ResponseEmitter;
/**
* Renders the main HTML page.
*/
class ClientManager
{
private string $mainHtmlFilePath = 'html/main.html';
private string $runScript = 'app.start();';
private string $faviconAlternate = 'client/img/favicon.ico';
private string $favicon = 'client/img/favicon.svg';
private string $basePath = '';
private string $apiUrl = 'api/v1';
private string $applicationId = 'espocrm';
private string $nonce;
private const APP_DESCRIPTION = "EspoCRM Open Source CRM application.";
public function __construct(
private Config $config,
private ThemeManager $themeManager,
private Metadata $metadata,
private FileManager $fileManager,
private DevModeJsFileListProvider $devModeJsFileListProvider,
private DevModeExtensionInitJsFileListProvider $devModeExtensionInitJsFileListProvider,
private Module $module,
private LoaderParamsProvider $loaderParamsProvider,
private SystemConfig $systemConfig,
private ApplicationConfig $applicationConfig,
private ThemeMetadataProvider $themeMetadataProvider,
) {
$this->nonce = Util::generateKey();
}
public function setBasePath(string $basePath): void
{
$this->basePath = $basePath;
}
public function getBasePath(): string
{
return $this->basePath;
}
/**
* @todo Move to a separate class.
*/
public function writeHeaders(Response $response, ?SecurityParams $params = null): void
{
if ($this->config->get('clientSecurityHeadersDisabled')) {
return;
}
$params ??= new SecurityParams();
$response->setHeader('X-Content-Type-Options', 'nosniff');
$this->writeContentSecurityPolicyHeader($response, $params);
$this->writeStrictTransportSecurityHeader($response);
}
private function writeContentSecurityPolicyHeader(Response $response, SecurityParams $params): void
{
if ($this->config->get('clientCspDisabled')) {
return;
}
$string = "script-src 'self' 'nonce-$this->nonce' 'unsafe-eval'";
/** @var string[] $scriptSourceList */
$scriptSourceList = $this->config->get('clientCspScriptSourceList') ?? [];
foreach ($scriptSourceList as $src) {
$string .= ' ' . $src;
}
if (!$this->config->get('clientCspFormActionDisabled')) {
$string .= "; form-action 'self'";
}
// Checking the parameter for bc.
if (!$this->config->get('clientXFrameOptionsHeaderDisabled')) {
$string .= '; frame-ancestors';
foreach (["'self'", ...$params->frameAncestors] as $item) {
$string .= ' ' . $item;
}
}
$response->setHeader('Content-Security-Policy', $string);
}
private function writeStrictTransportSecurityHeader(Response $response): void
{
if ($this->config->get('clientStrictTransportSecurityHeaderDisabled')) {
return;
}
$siteUrl = $this->applicationConfig->getSiteUrl();
if (str_starts_with($siteUrl, 'https://')) {
$response->setHeader('Strict-Transport-Security', 'max-age=10368000');
}
}
/**
* @param array<string, mixed> $vars
*/
public function display(?string $runScript = null, ?string $htmlFilePath = null, array $vars = []): void
{
$body = $this->renderInternal($runScript, $htmlFilePath, $vars);
$response = new ResponseWrapper(new Psr7Response());
$this->writeHeaders($response);
$response->writeBody($body);
(new ResponseEmitter())->emit($response->toPsr7());
}
/**
* Render.
*
* @param RenderParams $params Parameters.
* @return string A result HTML.
*/
public function render(RenderParams $params): string
{
return $this->renderInternal(
runScript: $params->runScript,
additionalScripts: $params->scripts,
pageTitle: $params->pageTitle,
theme: $params->theme,
);
}
/**
* @param array<string, mixed> $vars
* @param Script[] $additionalScripts
*/
private function renderInternal(
?string $runScript = null,
?string $htmlFilePath = null,
array $vars = [],
array $additionalScripts = [],
?string $pageTitle = null,
?string $theme = null,
): string {
$runScript ??= $this->runScript;
$htmlFilePath ??= $this->mainHtmlFilePath;
$cacheTimestamp = $this->getCacheTimestamp();
$jsFileList = $this->getJsFileList();
$appTimestamp = $this->getAppTimestamp();
if ($this->isDeveloperMode()) {
$useCache = $this->useCacheInDeveloperMode();
$loaderCacheTimestamp = null;
} else {
$useCache = $this->useCache();
$loaderCacheTimestamp = $appTimestamp;
}
$cssFileList = $this->metadata->get(['app', 'client', 'cssList'], []);
$linkList = $this->metadata->get(['app', 'client', 'linkList'], []);
$faviconAlternate = $this->metadata->get('app.client.faviconAlternate') ?? $this->faviconAlternate;
[$favicon, $faviconType] = $this->getFaviconData();
$scriptsHtml = implode('',
array_map(fn ($file) => $this->getScriptItemHtml($file, $appTimestamp), $jsFileList)
);
foreach ($additionalScripts as $it) {
$scriptsHtml .= $this->getScriptItemHtml(
file: null,
appTimestamp: $appTimestamp,
withNonce: true,
cache: $it->cacheBusting,
source: $it->source,
async: $it->async,
defer: $it->defer,
);
}
$additionalStyleSheetsHtml = implode('',
array_map(fn ($file) => $this->getCssItemHtml($file, $appTimestamp), $cssFileList)
);
$linksHtml = implode('',
array_map(fn ($item) => $this->getLinkItemHtml($item, $appTimestamp), $linkList)
);
$internalModuleList = array_map(
fn ($moduleName) => Util::fromCamelCase($moduleName, '-'),
$this->module->getInternalList()
);
$stylesheet = $theme ?
$this->themeMetadataProvider->getStylesheet($theme) :
$this->themeManager->getStylesheet();
$data = [
'applicationId' => $this->applicationId,
'apiUrl' => $this->apiUrl,
'applicationName' => $pageTitle ?? $this->config->get('applicationName', 'EspoCRM'),
'cacheTimestamp' => $cacheTimestamp,
'appTimestamp' => $appTimestamp,
'loaderCacheTimestamp' => Json::encode($loaderCacheTimestamp),
'stylesheet' => $stylesheet,
'theme' => Json::encode($theme),
'runScript' => $runScript,
'basePath' => $this->basePath,
'useCache' => $useCache ? 'true' : 'false',
'appClientClassName' => 'app',
'scriptsHtml' => $scriptsHtml,
'additionalStyleSheetsHtml' => $additionalStyleSheetsHtml,
'linksHtml' => $linksHtml,
'faviconAlternate' => $faviconAlternate,
'favicon' => $favicon,
'faviconType' => $faviconType,
'ajaxTimeout' => $this->config->get('ajaxTimeout') ?? 60000,
'internalModuleList' => Json::encode($internalModuleList),
'bundledModuleList' => Json::encode($this->getBundledModuleList()),
'applicationDescription' => $this->config->get('applicationDescription') ?? self::APP_DESCRIPTION,
'nonce' => $this->nonce,
'loaderParams' => Json::encode([
'basePath' => $this->basePath,
'cacheTimestamp' => $loaderCacheTimestamp,
'internalModuleList' => $internalModuleList,
'transpiledModuleList' => $this->getTranspiledModuleList(),
'libsConfig' => $this->loaderParamsProvider->getLibsConfig(),
'aliasMap' => $this->loaderParamsProvider->getAliasMap(),
]),
];
$html = $this->fileManager->getContents($htmlFilePath);
foreach ($vars as $key => $value) {
$html = str_replace('{{' . $key . '}}', $value, $html);
}
foreach ($data as $key => $value) {
if (array_key_exists($key, $vars)) {
continue;
}
$html = str_replace('{{' . $key . '}}', $value, $html);
}
return $html;
}
/**
* @return string[]
*/
private function getJsFileList(): array
{
if ($this->isDeveloperMode()) {
return array_merge(
$this->metadata->get(['app', 'client', 'developerModeScriptList']) ?? [],
$this->getDeveloperModeBundleLibFileList(),
$this->devModeExtensionInitJsFileListProvider->get(),
);
}
return $this->metadata->get(['app', 'client', 'scriptList']) ?? [];
}
/**
* @return string[]
*/
private function getDeveloperModeBundleLibFileList(): array
{
return $this->devModeJsFileListProvider->get();
}
private function isDeveloperMode(): bool
{
return (bool) $this->config->get('isDeveloperMode');
}
private function useCache(): bool
{
return $this->systemConfig->useCache();
}
private function useCacheInDeveloperMode(): bool
{
return (bool) $this->config->get('useCacheInDeveloperMode');
}
private function getCacheTimestamp(): int
{
if (!$this->useCache()) {
return time();
}
return $this->config->get('cacheTimestamp', 0);
}
private function getAppTimestamp(): int
{
if (!$this->useCache()) {
return time();
}
return $this->config->get('appTimestamp', 0);
}
private function getScriptItemHtml(
?string $file,
int $appTimestamp,
bool $withNonce = false,
bool $cache = true,
?string $source = null,
bool $async = false,
bool $defer = false,
): string {
$src = $source ?? $this->basePath . $file;
if ($cache) {
$src .= '?r=' . $appTimestamp;
}
$noncePart = '';
if ($withNonce) {
$noncePart = " nonce=\"$this->nonce\"";
}
$paramsPart = '';
if ($async) {
$paramsPart .= ' async';
}
if ($defer) {
$paramsPart .= ' defer';
}
return $this->getTabHtml() .
"<script src=\"$src\" data-base-path=\"$this->basePath\"$noncePart$paramsPart></script>";
}
private function getCssItemHtml(string $file, int $appTimestamp): string
{
$src = $this->basePath . $file . '?r=' . $appTimestamp;
return $this->getTabHtml() . "<link rel=\"stylesheet\" href=\"$src\">";
}
/**
* @param array{
* href: string,
* noTimestamp?: bool,
* as?: string,
* rel?: string,
* type?: string,
* crossorigin?: bool,
* } $item
*/
private function getLinkItemHtml(array $item, int $appTimestamp): string
{
$href = $this->basePath . $item['href'];
if (empty($item['noTimestamp'])) {
$href .= '?r=' . $appTimestamp;
}
$as = $item['as'] ?? '';
$rel = $item['rel'] ?? '';
$type = $item['type'] ?? '';
$part = '';
if ($item['crossorigin'] ?? false) {
$part .= ' crossorigin';
}
return $this->getTabHtml() .
"<link rel=\"$rel\" href=\"$href\" as=\"$as\" as=\"$type\"$part>";
}
private function getTabHtml(): string
{
return "\n ";
}
/**
* @return string[]
*/
private function getTranspiledModuleList(): array
{
$modules = array_values(array_filter(
$this->module->getList(),
fn ($item) => $this->module->get([$item, 'jsTranspiled'])
));
return array_map(
fn ($item) => Util::fromCamelCase($item, '-'),
$modules
);
}
/**
* @return string[]
*/
private function getBundledModuleList(): array
{
$modules = array_values(array_filter(
$this->module->getList(),
fn ($item) => $this->module->get([$item, 'bundled'])
));
return array_map(
fn ($item) => Util::fromCamelCase($item, '-'),
$modules
);
}
/**
* @since 8.0.0
*/
public function setApiUrl(string $apiUrl): void
{
$this->apiUrl = $apiUrl;
}
public function setApplicationId(string $applicationId): void
{
$this->applicationId = $applicationId;
}
/**
* @return array{string, string}
*/
private function getFaviconData(): array
{
$faviconSvgPath = $this->metadata->get('app.client.favicon') ?? $this->favicon;
$faviconType = str_ends_with($faviconSvgPath, '.svg') ? 'image/svg+xml' : 'image/png';
return [$faviconSvgPath, $faviconType];
}
}

View File

@@ -0,0 +1,429 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils;
use Espo\Core\Utils\Config\ConfigFileManager;
use stdClass;
use RuntimeException;
use const E_USER_DEPRECATED;
/**
* Access to the application config parameters.
*/
class Config
{
private string $systemConfigPath = 'application/Espo/Resources/defaults/systemConfig.php';
private string $configPath = 'data/config.php';
private string $internalConfigPath = 'data/config-internal.php';
private string $overrideConfigPath = 'data/config-override.php';
private string $internalOverrideConfigPath = 'data/config-internal-override.php';
private string $cacheTimestamp = 'cacheTimestamp';
/** @var string[] */
protected $associativeArrayAttributeList = [
'currencyRates',
'database',
'logger',
'defaultPermissions',
];
/** @var ?array<string, mixed> */
private $data = null;
/** @var array<string, mixed> */
private $changedData = [];
/** @var string[] */
private $removeData = [];
/** @var string[] */
private $internalParamList = [];
public function __construct(private ConfigFileManager $fileManager)
{}
/**
* A path to the config file.
*
* @todo Move to ConfigData.
*/
public function getConfigPath(): string
{
return $this->configPath;
}
/**
* A path to the internal config file.
*
* @todo Move to ConfigData.
*/
public function getInternalConfigPath(): string
{
return $this->internalConfigPath;
}
/**
* Get a parameter value.
*
* @param mixed $default
* @return mixed
*/
public function get(string $name, $default = null)
{
$keys = explode('.', $name);
$lastBranch = $this->getData();
foreach ($keys as $key) {
if (!is_array($lastBranch) && !is_object($lastBranch)) {
return $default;
}
if (is_array($lastBranch) && !array_key_exists($key, $lastBranch)) {
return $default;
}
if (is_object($lastBranch) && !property_exists($lastBranch, $key)) {
return $default;
}
if (is_array($lastBranch)) {
$lastBranch = $lastBranch[$key];
continue;
}
$lastBranch = $lastBranch->$key;
}
return $lastBranch;
}
/**
* Whether a parameter is set.
*/
public function has(string $name): bool
{
$keys = explode('.', $name);
$lastBranch = $this->getData();
foreach ($keys as $key) {
if (!is_array($lastBranch) && !is_object($lastBranch)) {
return false;
}
if (is_array($lastBranch) && !array_key_exists($key, $lastBranch)) {
return false;
}
if (is_object($lastBranch) && !property_exists($lastBranch, $key)) {
return false;
}
if (is_array($lastBranch)) {
$lastBranch = $lastBranch[$key];
continue;
}
$lastBranch = $lastBranch->$key;
}
return true;
}
/**
* Re-load data.
*
* @todo Get rid of this method. Use ConfigData as a dependency.
* `$configData->update();`
*/
public function update(): void
{
$this->load();
}
/**
* @deprecated As of v7.0. Use ConfigWriter instead.
*
* @param string|array<string, mixed>|stdClass $name
* @param mixed $value
*/
public function set($name, $value = null, bool $dontMarkDirty = false): void
{
if (is_object($name)) {
$name = get_object_vars($name);
}
if (!is_array($name)) {
$name = [$name => $value];
}
foreach ($name as $key => $value) {
if (in_array($key, $this->associativeArrayAttributeList) && is_object($value)) {
$value = (array) $value;
}
$this->data[$key] = $value;
if (!$dontMarkDirty) {
$this->changedData[$key] = $value;
}
}
}
/**
* @deprecated As of v7.0. Use ConfigWriter instead.
*/
public function remove(string $name): bool
{
assert($this->data !== null);
if (array_key_exists($name, $this->data)) {
unset($this->data[$name]);
$this->removeData[] = $name;
return true;
}
return false;
}
/**
* @deprecated As of v7.0. Use ConfigWriter instead.
* @return bool
*/
public function save()
{
trigger_error(
"Config::save is deprecated. Use `Espo\Core\Utils\Config\ConfigWriter` to save the config.",
E_USER_DEPRECATED
);
$values = $this->changedData;
if (!isset($values[$this->cacheTimestamp])) {
/** @noinspection PhpDeprecationInspection */
$values = array_merge($this->updateCacheTimestamp(true) ?? [], $values);
}
$removeData = empty($this->removeData) ? null : $this->removeData;
$configPath = $this->getConfigPath();
if (!$this->fileManager->isFile($configPath)) {
throw new RuntimeException("Config file '$configPath' is not found.");
}
$data = include($configPath);
if (!is_array($data)) {
$data = include($configPath);
}
if (is_array($values)) {
foreach ($values as $key => $value) {
$data[$key] = $value;
}
}
if (is_array($removeData)) {
foreach ($removeData as $key) {
unset($data[$key]);
}
}
if (!is_array($data)) {
throw new RuntimeException('Invalid config data while saving.');
}
$data['microtime'] = microtime(true);
$this->fileManager->putPhpContents($configPath, $data);
$this->changedData = [];
$this->removeData = [];
$this->load();
return true;
}
private function isLoaded(): bool
{
return !empty($this->data);
}
/**
* @return array<string, mixed>
*/
private function getData(): array
{
if (!$this->isLoaded()) {
$this->load();
}
assert($this->data !== null);
return $this->data;
}
private function load(): void
{
$systemData = $this->fileManager->getPhpContents($this->systemConfigPath);
$data = $this->readFile($this->configPath);
$internalData = $this->readFile($this->internalConfigPath);
$overrideData = $this->readFile($this->overrideConfigPath);
$internalOverrideData = $this->readFile($this->internalOverrideConfigPath);
$this->data = $this->mergeData(
$systemData,
$data,
$internalData,
$overrideData,
$internalOverrideData
);
$this->internalParamList = array_values(array_merge(
array_keys($internalData),
array_keys($internalOverrideData)
));
$this->fileManager->setConfig($this);
}
/**
* @param array<string, mixed> $systemData
* @param array<string, mixed> $data
* @param array<string, mixed> $internalData
* @param array<string, mixed> $overrideData
* @param array<string, mixed> $internalOverrideData
* @return array<string, mixed>
*/
private function mergeData(
array $systemData,
array $data,
array $internalData,
array $overrideData,
array $internalOverrideData
): array {
/** @var array<string, mixed> $mergedData */
$mergedData = Util::merge($systemData, $data);
/** @var array<string, mixed> $mergedData */
$mergedData = Util::merge($mergedData, $internalData);
/** @var array<string, mixed> $mergedData */
$mergedData = Util::merge($mergedData, $overrideData);
/** @var array<string, mixed> */
return Util::merge($mergedData, $internalOverrideData);
}
/**
* @return array<string, mixed>
*/
private function readFile(string $path): array
{
return $this->fileManager->isFile($path) ?
$this->fileManager->getPhpContents($path) : [];
}
/**
* Get all parameters excluding those that are set in the internal config.
*/
public function getAllNonInternalData(): stdClass
{
$data = (object) $this->getData();
foreach ($this->internalParamList as $param) {
unset($data->$param);
}
return $data;
}
/**
* Whether a parameter is set in the internal config.
*/
public function isInternal(string $name): bool
{
if (!$this->isLoaded()) {
$this->load();
}
return in_array($name, $this->internalParamList);
}
/**
* @deprecated As of 7.0. Use ConfigWriter instead.
* @param array<string, mixed> $data
* @return void
*/
public function setData($data)
{
if (is_object($data)) {
/** @noinspection PhpParamsInspection */
$data = get_object_vars($data);
}
/** @noinspection PhpDeprecationInspection */
$this->set($data);
}
/**
* @deprecated As of 7.0. Use ConfigWriter instead.
* @return ?array<string, int>
*/
public function updateCacheTimestamp(bool $returnOnlyValue = false)
{
$timestamp = [
$this->cacheTimestamp => time()
];
if ($returnOnlyValue) {
return $timestamp;
}
/** @noinspection PhpDeprecationInspection */
$this->set($timestamp);
return null;
}
/**
* @deprecated Use Espo\Core\Config\ApplicationConfig
*/
public function getSiteUrl(): string
{
return rtrim($this->get('siteUrl'), '/');
}
}

View File

@@ -0,0 +1,215 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils\Config;
use Espo\Core\Utils\Config;
use Espo\Core\Utils\Metadata;
use Espo\Core\Utils\FieldUtil;
use Espo\Entities\Settings;
use Espo\ORM\Defs\Params\FieldParam;
class Access
{
/** Logged-in users can read. Admin can write. */
public const LEVEL_DEFAULT = 'default';
/** No one can read/write. */
public const LEVEL_SYSTEM = 'system';
/** No one can read, admin can write. */
public const LEVEL_INTERNAL = 'internal';
/** Only super-admin can read/write. */
public const LEVEL_SUPER_ADMIN = 'superAdmin';
/** Only admin can read/write. */
public const LEVEL_ADMIN = 'admin';
/** Even not logged-in can read. Admin can write. */
public const LEVEL_GLOBAL = 'global';
public function __construct(
private Config $config,
private Metadata $metadata,
private FieldUtil $fieldUtil
) {}
/**
* Get read-only parameters.
*
* @return string[]
*/
public function getReadOnlyParamList(): array
{
$itemList = [];
$fieldDefs = $this->metadata->get(['entityDefs', Settings::ENTITY_TYPE, 'fields']);
foreach ($fieldDefs as $field => $fieldParams) {
if (empty($fieldParams[FieldParam::READ_ONLY])) {
continue;
}
foreach ($this->fieldUtil->getAttributeList(Settings::ENTITY_TYPE, $field) as $attribute) {
$itemList[] = $attribute;
}
}
$params = $this->metadata->get(['app', 'config', 'params']) ?? [];
foreach ($params as $name => $item) {
if ($item['readOnly'] ?? false) {
$itemList[] = $name;
}
}
return array_values(array_unique($itemList));
}
/**
* @return string[]
*/
public function getAdminParamList(): array
{
$itemList = $this->config->get('adminItems') ?? [];
$fieldDefs = $this->metadata->get(['entityDefs', Settings::ENTITY_TYPE, 'fields']);
foreach ($fieldDefs as $field => $fieldParams) {
if (empty($fieldParams['onlyAdmin'])) {
continue;
}
foreach ($this->fieldUtil->getAttributeList(Settings::ENTITY_TYPE, $field) as $attribute) {
$itemList[] = $attribute;
}
}
return array_values(
array_merge(
$itemList,
$this->getParamListByLevel(self::LEVEL_ADMIN)
)
);
}
/**
* @return string[]
*/
public function getInternalParamList(): array
{
return $this->getParamListByLevel(self::LEVEL_INTERNAL);
}
/**
* @return string[]
*/
public function getSystemParamList(): array
{
$itemList = $this->config->get('systemItems') ?? [];
$fieldDefs = $this->metadata->get(['entityDefs', Settings::ENTITY_TYPE, 'fields']);
foreach ($fieldDefs as $field => $fieldParams) {
if (empty($fieldParams['onlySystem'])) {
continue;
}
foreach ($this->fieldUtil->getAttributeList(Settings::ENTITY_TYPE, $field) as $attribute) {
$itemList[] = $attribute;
}
}
return array_values(
array_merge(
$itemList,
$this->getParamListByLevel(self::LEVEL_SYSTEM)
)
);
}
/**
* @return string[]
*/
public function getGlobalParamList(): array
{
$itemList = $this->config->get('globalItems', []);
$fieldDefs = $this->metadata->get(['entityDefs', Settings::ENTITY_TYPE, 'fields']);
foreach ($fieldDefs as $field => $fieldParams) {
if (empty($fieldParams['global'])) {
continue;
}
foreach ($this->fieldUtil->getAttributeList(Settings::ENTITY_TYPE, $field) as $attribute) {
$itemList[] = $attribute;
}
}
return array_values(
array_merge(
$itemList,
$this->getParamListByLevel(self::LEVEL_GLOBAL)
)
);
}
/**
* @return string[]
*/
public function getSuperAdminParamList(): array
{
return array_values(
array_merge(
$this->config->get('superAdminItems') ?? [],
$this->getParamListByLevel(self::LEVEL_SUPER_ADMIN)
)
);
}
/**
* @param self::LEVEL_* $level
* @return string[]
*/
private function getParamListByLevel(string $level): array
{
$itemList = [];
$params = $this->metadata->get(['app', 'config', 'params']) ?? [];
foreach ($params as $name => $item) {
$levelItem = $item['level'] ?? null;
if ($levelItem !== $level) {
continue;
}
$itemList[] = $name;
}
return $itemList;
}
}

View File

@@ -0,0 +1,75 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils\Config;
use Espo\Core\Utils\Config;
/**
* @since 9.0.0
*/
class ApplicationConfig
{
public function __construct(
private Config $config,
) {}
public function getSiteUrl(): string
{
return rtrim($this->config->get('siteUrl') ?? '', '/');
}
public function getDateFormat(): string
{
return $this->config->get('dateFormat') ?? 'DD.MM.YYYY';
}
public function getTimeFormat(): string
{
return $this->config->get('timeFormat') ?? 'HH:mm';
}
public function getTimeZone(): string
{
return $this->config->get('timeZone') ?? 'UTC';
}
public function getLanguage(): string
{
return $this->config->get('language') ?? 'en_US';
}
/**
* @since 9.2.0
*/
public function getRecordsPerPage(): int
{
return (int) $this->config->get('recordsPerPage');
}
}

View File

@@ -0,0 +1,101 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils\Config;
use Espo\Core\Utils\Config;
use Espo\Core\Utils\File\Manager as FileManager;
use RuntimeException;
class ConfigFileManager
{
protected FileManager $fileManager;
public function __construct()
{
$this->fileManager = new FileManager();
}
public function setConfig(Config $config): void
{
$this->fileManager = new FileManager(
$config->get('defaultPermissions')
);
}
public function isFile(string $filePath): bool
{
return $this->fileManager->isFile($filePath);
}
/**
* @param array<string, mixed> $data
* @throws RuntimeException
*/
protected function putPhpContentsInternal(string $path, array $data, bool $useRenaming = false): void
{
$result = $this->fileManager->putPhpContents($path, $data, true, $useRenaming);
if ($result === false) {
throw new RuntimeException();
}
}
/**
* @param array<string, mixed> $data
*/
public function putPhpContents(string $path, array $data): void
{
$this->putPhpContentsInternal($path, $data, true);
}
/**
* @param array<string, mixed> $data
*/
public function putPhpContentsNoRenaming(string $path, array $data): void
{
$this->putPhpContentsInternal($path, $data, false);
}
/**
* @return array<string, mixed>
*/
public function getPhpContents(string $path): array
{
$data = $this->fileManager->getPhpContents($path);
if (!is_array($data)) {
throw new RuntimeException("Bad data stored in '{$path}.");
}
/** @var array<string, mixed> */
return $data;
}
}

View File

@@ -0,0 +1,210 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils\Config;
use Espo\Core\Utils\Config;
use Exception;
use RuntimeException;
/**
* Writes into the config.
*/
class ConfigWriter
{
/** @var array<string, mixed> */
private $changedData = [];
/** @var string[] */
private $removeParamList = [];
/** @var string[] */
protected $associativeArrayAttributeList = [
'currencyRates',
'database',
'logger',
'defaultPermissions',
];
private string $cacheTimestampParam = 'cacheTimestamp';
public function __construct(
private Config $config,
private ConfigWriterFileManager $fileManager,
private ConfigWriterHelper $helper,
private InternalConfigHelper $internalConfigHelper
) {}
/**
* Set a parameter.
*
* @param mixed $value
*/
public function set(string $name, $value): void
{
if (in_array($name, $this->associativeArrayAttributeList) && is_object($value)) {
$value = (array) $value;
}
$this->changedData[$name] = $value;
}
/**
* Set multiple parameters.
*
* @param array<string, mixed> $params
*/
public function setMultiple(array $params): void
{
foreach ($params as $name => $value) {
$this->set($name, $value);
}
}
/**
* Remove a parameter.
*/
public function remove(string $name): void
{
$this->removeParamList[] = $name;
}
/**
* Save config changes to the file.
*/
public function save(): void
{
$changedData = $this->changedData;
if (!isset($changedData[$this->cacheTimestampParam])) {
$changedData[$this->cacheTimestampParam] = $this->generateCacheTimestamp();
}
$configPath = $this->config->getConfigPath();
$internalConfigPath = $this->config->getInternalConfigPath();
if (!$this->fileManager->isFile($configPath)) {
throw new RuntimeException("Config file '{$configPath}' not found.");
}
$data = $this->fileManager->getPhpContents($configPath);
$dataInternal = $this->fileManager->isFile($internalConfigPath) ?
$this->fileManager->getPhpContents($internalConfigPath) : [];
if (!is_array($data)) {
throw new RuntimeException("Could not read config.");
}
if (!is_array($dataInternal)) {
throw new RuntimeException("Could not read config-internal.");
}
$toSaveInternal = false;
foreach ($changedData as $key => $value) {
if ($this->internalConfigHelper->isParamForInternalConfig($key)) {
$dataInternal[$key] = $value;
unset($data[$key]);
$toSaveInternal = true;
continue;
}
$data[$key] = $value;
}
foreach ($this->removeParamList as $key) {
if ($this->internalConfigHelper->isParamForInternalConfig($key)) {
unset($dataInternal[$key]);
$toSaveInternal = true;
continue;
}
unset($data[$key]);
}
if ($toSaveInternal) {
$this->saveData($internalConfigPath, $dataInternal, 'microtimeInternal');
}
$this->saveData($configPath, $data, 'microtime');
$this->changedData = [];
$this->removeParamList = [];
$this->config->update();
}
/**
* @param array<string, mixed> $data
*/
private function saveData(string $path, array &$data, string $timeParam): void
{
$data[$timeParam] = $microtime = $this->helper->generateMicrotime();
try {
$this->fileManager->putPhpContents($path, $data);
} catch (Exception) {
throw new RuntimeException("Could not save config.");
}
$reloadedData = $this->fileManager->getPhpContents($path);
if (
is_array($reloadedData) &&
$microtime === ($reloadedData[$timeParam] ?? null)
) {
return;
}
try {
$this->fileManager->putPhpContentsNoRenaming($path, $data);
} catch (Exception) {
throw new RuntimeException("Could not save config.");
}
}
/**
* Update the cache timestamp.
*
* @todo Remove? Saving re-writes the cache timestamp anyway.
*/
public function updateCacheTimestamp(): void
{
$this->set($this->cacheTimestampParam, $this->generateCacheTimestamp());
}
protected function generateCacheTimestamp(): int
{
return $this->helper->generateCacheTimestamp();
}
}

View File

@@ -0,0 +1,122 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils\Config;
use Espo\Core\Utils\Config;
use Espo\Core\Utils\File\Manager as FileManager;
use RuntimeException;
class ConfigWriterFileManager
{
private FileManager $fileManager;
/**
* @param ?array{
* dir: string|int|null,
* file: string|int|null,
* user: string|int|null,
* group: string|int|null,
* } $defaultPermissions
*/
public function __construct(?Config $config = null, ?array $defaultPermissions = null)
{
$defaultPermissionsToSet = null;
if ($defaultPermissions) {
$defaultPermissionsToSet = $defaultPermissions;
} else if ($config) {
$defaultPermissionsToSet = $config->get('defaultPermissions');
}
$this->fileManager = new FileManager($defaultPermissionsToSet);
}
public function setConfig(Config $config): void
{
$this->fileManager = new FileManager(
$config->get('defaultPermissions')
);
}
public function isFile(string $filePath): bool
{
return $this->fileManager->isFile($filePath);
}
/**
* @param array<string, mixed> $data
*/
protected function putPhpContentsInternal(string $path, array $data, bool $useRenaming = false): void
{
$result = $this->fileManager->putPhpContents($path, $data, true, $useRenaming);
if ($result === false) {
throw new RuntimeException();
}
}
/**
* @param array<string, mixed> $data $data
*/
public function putPhpContents(string $path, array $data): void
{
$this->putPhpContentsInternal($path, $data, true);
}
/**
* @param array<string, mixed> $data
*/
public function putPhpContentsNoRenaming(string $path, array $data): void
{
$this->putPhpContentsInternal($path, $data, false);
}
/**
* Supposed to return array. False means the file is being written or corrupted.
* @return array<string, mixed>|false
*/
public function getPhpContents(string $path)
{
try {
$data = $this->fileManager->getPhpContents($path);
} catch (RuntimeException) {
return false;
}
if (!is_array($data)) {
return false;
}
/** @var array<string, mixed> */
return $data;
}
}

View File

@@ -0,0 +1,43 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils\Config;
class ConfigWriterHelper
{
public function generateCacheTimestamp(): int
{
return time();
}
public function generateMicrotime(): float
{
return microtime(true);
}
}

View File

@@ -0,0 +1,58 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils\Config;
use Espo\Core\Utils\Config;
use Espo\Core\Utils\Metadata;
class InternalConfigHelper
{
public function __construct(private Config $config, private Metadata $metadata)
{}
public function isParamForInternalConfig(string $name): bool
{
if ($this->config->isInternal($name)) {
return true;
}
if (in_array($name, $this->config->get('systemItems') ?? [])) {
return true;
}
$level = $this->metadata->get(['app', 'config', 'params', $name, 'level']);
if ($level === Access::LEVEL_SYSTEM || $level === Access::LEVEL_INTERNAL) {
return true;
}
return false;
}
}

View File

@@ -0,0 +1,74 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils\Config;
use Espo\Core\Utils\Config;
use Espo\Core\Utils\File\Manager as FileManager;
use RuntimeException;
class MissingDefaultParamsSaver
{
private string $defaultConfigPath = 'application/Espo/Resources/defaults/config.php';
public function __construct(
private Config $config,
private ConfigWriter $configWriter,
private FileManager $fileManager
) {}
public function process(): void
{
$data = $this->fileManager->getPhpSafeContents($this->defaultConfigPath);
if (!is_array($data)) {
throw new RuntimeException();
}
/** @var array<string, mixed> $data */
$newData = [];
foreach ($data as $param => $value) {
if ($this->config->has($param)) {
continue;
}
$newData[$param] = $value;
}
if (!count($newData)) {
return;
}
$this->configWriter->setMultiple($newData);
$this->configWriter->save();
}
}

View File

@@ -0,0 +1,63 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils\Config;
use Espo\Core\Utils\Config;
/**
* @since 9.1.0
*/
class SystemConfig
{
public function __construct(
private Config $config,
) {}
public function useCache(): bool
{
return (bool) $this->config->get('useCache');
}
public function getVersion(): string
{
return (string) $this->config->get('version');
}
/**
* Is restricted mode.
*
* @since 9.1.8
*/
public function isRestrictedMode(): bool
{
return (bool) $this->config->get('restrictedMode');
}
}

View File

@@ -0,0 +1,104 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils;
use RuntimeException;
class Crypt
{
private string $cryptKey;
private ?string $key = null;
private ?string $iv = null;
public function __construct(Config $config)
{
$this->cryptKey = $config->get('cryptKey', '');
}
private function getKey(): string
{
if ($this->key === null) {
$this->key = hash('sha256', $this->cryptKey, true);
}
if (!$this->key) {
throw new RuntimeException("Could not hash the key.");
}
return $this->key;
}
private function getIv(): string
{
if ($this->iv === null) {
if (!extension_loaded('openssl')) {
throw new RuntimeException("openssl extension is not loaded.");
}
$iv = openssl_random_pseudo_bytes(16);
$this->iv = $iv;
}
return $this->iv;
}
public function encrypt(string $string): string
{
$iv = $this->getIv();
if (!extension_loaded('openssl')) {
throw new RuntimeException("openssl extension is not loaded.");
}
return base64_encode(
openssl_encrypt($string, 'aes-256-cbc', $this->getKey(), OPENSSL_RAW_DATA, $iv) . $iv
);
}
public function decrypt(string $encryptedString): string
{
$encryptedStringDecoded = base64_decode($encryptedString);
$string = substr($encryptedStringDecoded, 0, strlen($encryptedStringDecoded) - 16);
$iv = substr($encryptedStringDecoded, -16);
if (!extension_loaded('openssl')) {
throw new RuntimeException("openssl extension is not loaded.");
}
$value = openssl_decrypt($string, 'aes-256-cbc', $this->getKey(), OPENSSL_RAW_DATA, $iv);
if ($value === false) {
throw new RuntimeException("OpenSSL decrypt failure.");
}
return trim($value);
}
}

View File

@@ -0,0 +1,94 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils\Currency;
use Espo\Entities\Currency;
use Espo\ORM\EntityManager;
use Espo\Core\Utils\Config;
use Espo\ORM\Name\Attribute;
/**
* Populates currency rates into database.
*/
class DatabasePopulator
{
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');
if ($defaultCurrency !== $baseCurrency) {
$currencyRates = $this->exchangeRates($baseCurrency, $defaultCurrency, $currencyRates);
}
$currencyRates[$defaultCurrency] = 1.00;
$delete = $this->entityManager->getQueryBuilder()
->delete()
->from(Currency::ENTITY_TYPE)
->build();
$this->entityManager->getQueryExecutor()->execute($delete);
foreach ($currencyRates as $currencyName => $rate) {
$this->entityManager->createEntity(Currency::ENTITY_TYPE, [
Attribute::ID => $currencyName,
'rate' => $rate,
]);
}
}
/**
* @param array<string, float> $currencyRates
* @return array<string, float>
*/
private function exchangeRates(string $baseCurrency, string $defaultCurrency, array $currencyRates): array
{
$precision = 5;
$defaultCurrencyRate = round(1 / $currencyRates[$defaultCurrency], $precision);
$exchangedRates = [];
$exchangedRates[$baseCurrency] = $defaultCurrencyRate;
unset($currencyRates[$baseCurrency], $currencyRates[$defaultCurrency]);
foreach ($currencyRates as $currencyName => $rate) {
$exchangedRates[$currencyName] = round($rate * $defaultCurrencyRate, $precision);
}
return $exchangedRates;
}
}

View File

@@ -0,0 +1,125 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils;
use Espo\Core\Utils\File\Manager as FileManager;
use InvalidArgumentException;
use RuntimeException;
use stdClass;
class DataCache
{
protected string $cacheDir = 'data/cache/application/';
public function __construct(protected FileManager $fileManager)
{}
/**
* Whether is cached.
*/
public function has(string $key): bool
{
$cacheFile = $this->getCacheFile($key);
return $this->fileManager->isFile($cacheFile);
}
/**
* Get a stored value.
*
* @return array<int|string, mixed>|stdClass
*/
public function get(string $key)
{
$cacheFile = $this->getCacheFile($key);
return $this->fileManager->getPhpSafeContents($cacheFile);
}
/**
* Store in cache.
*
* @param array<int|string, mixed>|stdClass $data
*/
public function store(string $key, $data): void
{
/** @phpstan-var mixed $data */
if (!$this->checkDataIsValid($data)) {
throw new InvalidArgumentException("Bad cache data type.");
}
$cacheFile = $this->getCacheFile($key);
$result = $this->fileManager->putPhpContents($cacheFile, $data, true, true);
if ($result === false) {
throw new RuntimeException("Could not store '$key'.");
}
}
/**
* Removes in cache.
*/
public function clear(string $key): void
{
$cacheFile = $this->getCacheFile($key);
$this->fileManager->removeFile($cacheFile);
}
/**
* @param mixed $data
* @return bool
*/
private function checkDataIsValid($data)
{
$isInvalid =
!is_array($data) &&
!$data instanceof stdClass;
return !$isInvalid;
}
private function getCacheFile(string $key): string
{
if (
$key === '' ||
preg_match('/[^a-zA-Z0-9_\/\-]/i', $key) ||
$key[0] === '/' ||
str_ends_with($key, '/')
) {
throw new InvalidArgumentException("Bad cache key.");
}
return $this->cacheDir . $key . '.php';
}
}

View File

@@ -0,0 +1,253 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils;
use InvalidArgumentException;
use LogicException;
use RuntimeException;
use stdClass;
class DataUtil
{
/**
* @param array<string|int, mixed>|stdClass $data
* @param array<int, string|string[]>|string $unsetList
* @return array<string|int, mixed>|stdClass
*/
public static function unsetByKey(&$data, $unsetList, bool $removeEmptyItems = false)
{
if (empty($unsetList)) {
return $data;
}
if (is_string($unsetList)) {
$unsetList = [$unsetList];
} else if (!is_array($unsetList)) {
throw new InvalidArgumentException();
}
foreach ($unsetList as $unsetItem) {
if (is_array($unsetItem)) {
$path = $unsetItem;
} else if (is_string($unsetItem)) {
$path = explode('.', $unsetItem);
} else {
throw new LogicException('Bad unset parameter');
}
$pointer = &$data;
$elementArr = [];
$elementArr[] = &$pointer;
foreach ($path as $i => $key) {
if ($i === count($path) - 1) {
if (is_array($pointer)) {
if (array_key_exists($key, $pointer)) {
unset($pointer[$key]);
}
continue;
}
if (!is_object($pointer)) {
continue;
}
unset($pointer->$key);
if (!$removeEmptyItems) {
continue;
}
for ($j = count($elementArr); $j > 0; $j--) {
$pointerBack =& $elementArr[$j];
if (is_object($pointerBack) && count(get_object_vars($pointerBack)) === 0) {
$previous =& $elementArr[$j - 1];
if (is_object($previous)) {
$key = $path[$j - 1];
unset($previous->$key);
}
}
}
continue;
}
if (is_array($pointer)) {
$pointer = &$pointer[$key];
} else if (is_object($pointer)) {
if (!property_exists($pointer, $key)) {
break;
}
$pointer = &$pointer->$key;
}
$elementArr[] = &$pointer;
}
}
return $data;
}
/**
* @param array<string|int, mixed>|stdClass $data
* @param mixed $needle
* @return array<string|int, mixed>|stdClass
*/
public static function unsetByValue(&$data, $needle)
{
if (is_object($data)) {
foreach (get_object_vars($data) as $key => $value) {
self::unsetByValue($data->$key, $needle);
if ($data->$key === $needle) {
unset($data->$key);
}
}
} else if (is_array($data)) {
$doReindex = false;
foreach ($data as $key => $value) {
self::unsetByValue($data[$key], $needle);
if ($data[$key] === $needle) {
unset($data[$key]);
$doReindex = true;
}
}
if ($doReindex) {
$data = array_values($data);
}
}
return $data;
}
/**
* @param array<string, mixed>|stdClass $data
* @param array<string, mixed>|stdClass $overrideData
* @return array<string|int, mixed>|stdClass
*/
public static function merge($data, $overrideData)
{
$appendIdentifier = '__APPEND__';
/** @var mixed $data */
/** @var mixed $overrideData */
if (empty($data) && empty($overrideData)) {
if (is_array($data) || is_array($overrideData)) {
return [];
}
/** @var array<string|int, mixed>|stdClass */
return $overrideData; /** @phpstan-ignore-line */
}
if (is_object($overrideData)) {
if (empty($data)) {
$data = (object) [];
}
foreach (get_object_vars($overrideData) as $key => $value) {
if (isset($data->$key)) {
$data->$key = self::merge($data->$key, $overrideData->$key);
} else {
$data->$key = $overrideData->$key;
self::unsetByValue($data->$key, $appendIdentifier);
}
}
return $data;
}
if (is_array($overrideData)) {
if (empty($data)) {
$data = [];
}
/** @var array<string, mixed> $data */
if (in_array($appendIdentifier, $overrideData)) {
foreach ($overrideData as $item) {
if ($item === $appendIdentifier) {
continue;
}
$data[] = $item;
}
return $data;
}
return $overrideData;
}
/** @var array<string|int, mixed>|stdClass */
return $overrideData;
}
/**
* @param string[] $path
* @since 9.2.0
* @internal
*/
public static function setByPath(stdClass $data, array $path, mixed $value): void
{
if (count($path) === 1) {
$property = $path[0];
$data->$property = $value;
return;
}
if (count($path) < 1) {
return;
}
$property = array_shift($path);
if (!isset($data->$property)) {
$data->$property = (object) [];
}
if (!($data->$property instanceof stdClass)) {
throw new RuntimeException("Cannot set by path. Not an object.");
}
self::setByPath($data->$property, $path, $value);
}
}

View File

@@ -0,0 +1,35 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils\Database;
interface ConfigDataProvider
{
public function getPlatform(): string;
}

View File

@@ -0,0 +1,38 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils\Database\Dbal;
use Doctrine\DBAL\Connection;
use Espo\ORM\DatabaseParams;
interface ConnectionFactory
{
public function create(DatabaseParams $databaseParams): Connection;
}

View File

@@ -0,0 +1,61 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils\Database\Dbal;
use Espo\Core\Binding\BindingContainerBuilder;
use Espo\Core\InjectableFactory;
use Espo\Core\Utils\Metadata;
use PDO;
use RuntimeException;
class ConnectionFactoryFactory
{
public function __construct(
private Metadata $metadata,
private InjectableFactory $injectableFactory
) {}
public function create(string $platform, PDO $pdo): ConnectionFactory
{
/** @var ?class-string<ConnectionFactory> $className */
$className = $this->metadata
->get(['app', 'databasePlatforms', $platform, 'dbalConnectionFactoryClassName']);
if (!$className) {
throw new RuntimeException("No DBAL ConnectionFactory for {$platform}.");
}
$bindingContainer = BindingContainerBuilder::create()
->bindInstance(PDO::class, $pdo)
->build();
return $this->injectableFactory->createWithBinding($className, $bindingContainer);
}
}

View File

@@ -0,0 +1,88 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils\Database\Dbal\Factories;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Driver\PDO\MySQL\Driver as PDOMySQLDriver;
use Doctrine\DBAL\Exception as DBALException;
use Espo\Core\Utils\Database\Dbal\ConnectionFactory;
use Espo\ORM\DatabaseParams;
use Espo\ORM\PDO\Options as PdoOptions;
use PDO;
use RuntimeException;
class MysqlConnectionFactory implements ConnectionFactory
{
private const DEFAULT_CHARSET = 'utf8mb4';
public function __construct(
private PDO $pdo
) {}
/**
* @throws DBALException
*/
public function create(DatabaseParams $databaseParams): Connection
{
$driver = new PDOMySQLDriver();
if (!$databaseParams->getHost()) {
throw new RuntimeException("No database host in config.");
}
$params = [
'pdo' => $this->pdo,
'host' => $databaseParams->getHost(),
'driverOptions' => PdoOptions::getOptionsFromDatabaseParams($databaseParams),
];
if ($databaseParams->getName() !== null) {
$params['dbname'] = $databaseParams->getName();
}
if ($databaseParams->getPort() !== null) {
$params['port'] = $databaseParams->getPort();
}
if ($databaseParams->getUsername() !== null) {
$params['user'] = $databaseParams->getUsername();
}
if ($databaseParams->getPassword() !== null) {
$params['password'] = $databaseParams->getPassword();
}
$params['charset'] = $databaseParams->getCharset() ?? self::DEFAULT_CHARSET;
return new Connection($params, $driver);
}
}

View File

@@ -0,0 +1,97 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils\Database\Dbal\Factories;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Driver\PDO\PgSQL\Driver as PostgreSQLDriver;
use Doctrine\DBAL\Exception as DBALException;
use Espo\Core\Utils\Database\Dbal\ConnectionFactory;
use Espo\Core\Utils\Database\Dbal\Platforms\PostgresqlPlatform;
use Espo\Core\Utils\Database\Helper;
use Espo\ORM\DatabaseParams;
use Espo\ORM\PDO\Options as PdoOptions;
use PDO;
use RuntimeException;
class PostgresqlConnectionFactory implements ConnectionFactory
{
private const DEFAULT_CHARSET = 'utf8';
public function __construct(
private PDO $pdo,
private Helper $helper
) {}
/**
* @throws DBALException
*/
public function create(DatabaseParams $databaseParams): Connection
{
$driver = new PostgreSQLDriver();
if (!$databaseParams->getHost()) {
throw new RuntimeException("No database host in config.");
}
$platform = new PostgresqlPlatform();
if ($databaseParams->getName()) {
$platform->setTextSearchConfig($this->helper->getParam('default_text_search_config'));
}
$params = [
'platform' => $platform,
'pdo' => $this->pdo,
'host' => $databaseParams->getHost(),
'driverOptions' => PdoOptions::getOptionsFromDatabaseParams($databaseParams),
];
if ($databaseParams->getName() !== null) {
$params['dbname'] = $databaseParams->getName();
}
if ($databaseParams->getPort() !== null) {
$params['port'] = $databaseParams->getPort();
}
if ($databaseParams->getUsername() !== null) {
$params['user'] = $databaseParams->getUsername();
}
if ($databaseParams->getPassword() !== null) {
$params['password'] = $databaseParams->getPassword();
}
$params['charset'] = $databaseParams->getCharset() ?? self::DEFAULT_CHARSET;
return new Connection($params, $driver);
}
}

View File

@@ -0,0 +1,52 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils\Database\Dbal\Platforms\Keywords;
use Doctrine\DBAL\Platforms\Keywords\MariaDBKeywords;
/**
* 'LEAD' happened to be a reserved words on some environments.
*/
class MariaDb102Keywords extends MariaDBKeywords
{
/** @deprecated */
public function getName(): string
{
return 'MariaDb102';
}
protected function getKeywords(): array
{
return [
...parent::getKeywords(),
'LEAD',
];
}
}

View File

@@ -0,0 +1,98 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils\Database\Dbal\Platforms;
use Doctrine\DBAL\Schema\Index;
use Doctrine\DBAL\Schema\PostgreSQLSchemaManager as BasePostgreSQLSchemaManager;
class PostgreSQLSchemaManager extends BasePostgreSQLSchemaManager
{
/**
* DBAL does not add the 'fulltext' flag on reverse engineering.
*/
protected function _getPortableTableIndexesList($tableIndexes, $tableName = null)
{
$indexes = parent::_getPortableTableIndexesList($tableIndexes, $tableName);
foreach ($tableIndexes as $row) {
$key = $row['relname'];
if ($key === "idx_{$tableName}_system_full_text_search") {
$sql = "SELECT indexdef FROM pg_indexes WHERE indexname = '{$key}'";
$rows = $this->_conn->fetchAllAssociative($sql);
if (!$rows) {
continue;
}
$columns = self::parseColumnsIndexFromDeclaration($rows[0]['indexdef']);
$indexes[$key] = new Index(
$key,
$columns,
false,
false,
['fulltext']
);
}
}
return $indexes;
}
/**
* @return string[]
*/
private static function parseColumnsIndexFromDeclaration(string $string): array
{
preg_match('/to_tsvector\((.*),(.*)\)/i', $string, $matches);
if (!$matches || count($matches) < 3) {
return [];
}
$part = $matches[2];
$part = str_replace("|| ' '::text", '', $part);
$part = str_replace("::text", '', $part);
$part = str_replace(" ", '', $part);
$part = str_replace("||", ' ', $part);
$part = str_replace("(", '', $part);
$part = str_replace(")", '', $part);
$list = array_map(
fn ($item) => trim($item),
explode(' ', $part)
);
return $list;
}
}

View File

@@ -0,0 +1,83 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils\Database\Dbal\Platforms;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Platforms\PostgreSQL100Platform;
use Doctrine\DBAL\Schema\Index;
use Doctrine\DBAL\Schema\Table;
class PostgresqlPlatform extends PostgreSQL100Platform
{
private const TEXT_SEARCH_CONFIG = 'pg_catalog.simple';
private ?string $textSearchConfig;
public function setTextSearchConfig(?string $textSearchConfig): void
{
$this->textSearchConfig = $textSearchConfig;
}
public function createSchemaManager(Connection $connection): PostgreSQLSchemaManager
{
return new PostgreSQLSchemaManager($connection, $this);
}
public function getCreateIndexSQL(Index $index, $table)
{
if (!$index->hasFlag('fulltext')) {
return parent::getCreateIndexSQL($index, $table);
}
if ($table instanceof Table) {
$table = $table->getQuotedName($this);
}
$name = $index->getQuotedName($this);
$columns = $index->getColumns();
if (count($columns) === 0) {
throw new \InvalidArgumentException(sprintf(
'Incomplete or invalid index definition %s on table %s',
$name,
$table,
));
}
$columnsPart = implode(" || ' ' || ", $index->getQuotedColumns($this));
$partialPart = $this->getPartialIndexSQL($index);
$textSearchConfig = $this->textSearchConfig ?? self::TEXT_SEARCH_CONFIG;
$textSearchConfig = preg_replace('/[^A-Za-z0-9_.\-]+/', '', $textSearchConfig) ?? '';
$configPart = $this->quoteStringLiteral($textSearchConfig);
return "CREATE INDEX {$name} ON {$table} USING GIN (TO_TSVECTOR({$configPart}, {$columnsPart})) {$partialPart}";
}
}

View File

@@ -0,0 +1,51 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils\Database\Dbal\Types;
use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Types\TextType;
/**
* MySQL only.
*/
class LongtextType extends TextType
{
public const NAME = 'longtext';
public function getName()
{
return self::NAME;
}
public function getSQLDeclaration(array $column, AbstractPlatform $platform)
{
return 'LONGTEXT';
}
}

View File

@@ -0,0 +1,51 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils\Database\Dbal\Types;
use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Types\TextType;
/**
* MySQL only.
*/
class MediumtextType extends TextType
{
public const NAME = 'mediumtext';
public function getName()
{
return self::NAME;
}
public function getSQLDeclaration(array $column, AbstractPlatform $platform)
{
return 'MEDIUMTEXT';
}
}

View File

@@ -0,0 +1,51 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils\Database\Dbal\Types;
use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Types\Type;
/**
* Supported in MariaDB from v10.7.
*/
class UuidType extends Type
{
public const NAME = 'uuid';
public function getName()
{
return self::NAME;
}
public function getSQLDeclaration(array $column, AbstractPlatform $platform)
{
return 'UUID';
}
}

View File

@@ -0,0 +1,44 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils\Database;
use Espo\Core\Utils\Config;
class DefaultConfigDataProvider implements ConfigDataProvider
{
private const DEFAULT_PLATFORM = 'Mysql';
public function __construct(private Config $config) {}
public function getPlatform(): string
{
return $this->config->get('database.platform') ?? self::DEFAULT_PLATFORM;
}
}

View File

@@ -0,0 +1,41 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils\Database;
interface DetailsProvider
{
public function getType(): string;
public function getVersion(): string;
public function getServerVersion(): string;
public function getParam(string $name): ?string;
}

View File

@@ -0,0 +1,61 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils\Database;
use Espo\Core\Binding\BindingContainerBuilder;
use Espo\Core\InjectableFactory;
use Espo\Core\Utils\Metadata;
use PDO;
use RuntimeException;
class DetailsProviderFactory
{
public function __construct(
private InjectableFactory $injectableFactory,
private Metadata $metadata
) {}
public function create(string $platform, PDO $pdo): DetailsProvider
{
/** @var ?class-string<DetailsProvider> $className */
$className = $this->metadata
->get(['app', 'databasePlatforms', $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);
}
}

View File

@@ -0,0 +1,107 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils\Database\DetailsProviders;
use Espo\Core\Utils\Database\DetailsProvider;
use PDO;
class MysqlDetailsProvider implements DetailsProvider
{
public const TYPE_MYSQL = 'MySQL';
public const TYPE_MARIADB = 'MariaDB';
public function __construct(
private PDO $pdo
) {}
public function getType(): string
{
$version = $this->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;
}
}

View File

@@ -0,0 +1,106 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils\Database\DetailsProviders;
use Espo\Core\Utils\Database\DetailsProvider;
use PDO;
class PostgresqlDetailsProvider implements DetailsProvider
{
private const TYPE_POSTGRESQL = 'PostgreSQL';
public function __construct(private PDO $pdo)
{}
public function getType(): string
{
return self::TYPE_POSTGRESQL;
}
public function getVersion(): string
{
$fullVersion = $this->getFullDatabaseVersion() ?? '';
if (preg_match('/[0-9]+\.[0-9]+/', $fullVersion, $match)) {
return $match[0];
}
return '0.0';
}
public function getServerVersion(): string
{
return (string) $this->getFullDatabaseVersion();
}
public function getParam(string $name): ?string
{
$name = preg_replace('/[^A-Za-z0-9_]+/', '', $name);
$sql = "SHOW {$name}";
$sth = $this->pdo->query($sql);
if ($sth === false) {
return null;
}
$row = $sth->fetch(PDO::FETCH_NUM);
if ($row === false) {
return null;
}
$value = $row[0] ?: 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;
}
}

View File

@@ -0,0 +1,150 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils\Database;
use Doctrine\DBAL\Connection as DbalConnection;
use Espo\Core\ORM\DatabaseParamsFactory;
use Espo\Core\ORM\PDO\PDOFactoryFactory;
use Espo\Core\Utils\Database\Dbal\ConnectionFactoryFactory as DBALConnectionFactoryFactory;
use Espo\ORM\DatabaseParams;
use PDO;
use RuntimeException;
class Helper
{
private ?DbalConnection $dbalConnection = null;
private ?PDO $pdo = null;
public function __construct(
private PDOFactoryFactory $pdoFactoryFactory,
private DBALConnectionFactoryFactory $dbalConnectionFactoryFactory,
private ConfigDataProvider $configDataProvider,
private DetailsProviderFactory $detailsProviderFactory,
private DatabaseParamsFactory $databaseParamsFactory
) {}
public function getDbalConnection(): DbalConnection
{
if (!isset($this->dbalConnection)) {
$this->dbalConnection = $this->createDbalConnection();
}
return $this->dbalConnection;
}
public function getPDO(): PDO
{
if (!isset($this->pdo)) {
$this->pdo = $this->createPDO();
}
return $this->pdo;
}
/**
* Clone with another PDO connection.
*/
public function withPDO(PDO $pdo): self
{
$obj = clone $this;
$obj->pdo = $pdo;
$obj->dbalConnection = null;
return $obj;
}
/**
* Create a PDO connection.
*/
public function createPDO(?DatabaseParams $params = null): PDO
{
$params = $params ?? $this->databaseParamsFactory->create();
return $this->pdoFactoryFactory
->create($params->getPlatform() ?? '')
->create($params);
}
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->getPDO())
->create($params);
}
/**
* Get a database type (MySQL, MariaDB, PostgreSQL).
*/
public function getType(): string
{
return $this->createDetailsProvider()->getType();
}
/**
* Get a database version.
*/
public function getVersion(): string
{
return $this->createDetailsProvider()->getVersion();
}
/**
* Get a database parameter.
*/
public function getParam(string $name): ?string
{
return $this->createDetailsProvider()->getParam($name);
}
/**
* Get a database server version string.
*/
public function getServerVersion(): string
{
return $this->createDetailsProvider()->getServerVersion();
}
private function createDetailsProvider(): DetailsProvider
{
$platform = $this->configDataProvider->getPlatform();
return $this->detailsProviderFactory->create($platform, $this->getPDO());
}
}

View File

@@ -0,0 +1,54 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils\Database;
use Doctrine\DBAL\Types\Types;
use Espo\Core\Utils\Metadata;
class MetadataProvider
{
private const DEFAULT_ID_LENGTH = 24;
private const DEFAULT_ID_DB_TYPE = Types::STRING;
public function __construct(private Metadata $metadata)
{}
public function getIdLength(): int
{
return $this->metadata->get(['app', 'recordId', 'length']) ??
self::DEFAULT_ID_LENGTH;
}
public function getIdDbType(): string
{
return $this->metadata->get(['app', 'recordId', 'dbType']) ??
self::DEFAULT_ID_DB_TYPE;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,178 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils\Database\Orm\Defs;
use Espo\Core\Utils\Util;
use Espo\ORM\Defs\Params\AttributeParam;
use Espo\ORM\Type\AttributeType;
/**
* Immutable.
*/
class AttributeDefs
{
/** @var array<string, mixed> */
private array $params = [];
private function __construct(private string $name) {}
public static function create(string $name): self
{
return new self($name);
}
/**
* Get an attribute name.
*/
public function getName(): string
{
return $this->name;
}
/**
* Get a type.
*
* @return AttributeType::*
*/
public function getType(): ?string
{
/** @var ?AttributeType::* $value */
$value = $this->getParam(AttributeParam::TYPE);
return $value;
}
/**
* Clone with a type.
*
* @param AttributeType::* $type
*/
public function withType(string $type): self
{
return $this->withParam(AttributeParam::TYPE, $type);
}
/**
* Clone with a DB type.
*/
public function withDbType(string $dbType): self
{
return $this->withParam(AttributeParam::DB_TYPE, $dbType);
}
/**
* Clone with not-storable.
*/
public function withNotStorable(bool $value = true): self
{
return $this->withParam(AttributeParam::NOT_STORABLE, $value);
}
/**
* Clone with a length.
*/
public function withLength(int $length): self
{
return $this->withParam(AttributeParam::LEN, $length);
}
/**
* Clone with a default value.
*/
public function withDefault(mixed $value): self
{
return $this->withParam(AttributeParam::DEFAULT, $value);
}
/**
* Whether a parameter is set.
*/
public function hasParam(string $name): bool
{
return array_key_exists($name, $this->params);
}
/**
* Get a parameter value.
*/
public function getParam(string $name): mixed
{
return $this->params[$name] ?? null;
}
/**
* Clone with a parameter.
*/
public function withParam(string $name, mixed $value): self
{
$obj = clone $this;
$obj->params[$name] = $value;
return $obj;
}
/**
* Clone without a parameter.
*/
public function withoutParam(string $name): self
{
$obj = clone $this;
unset($obj->params[$name]);
return $obj;
}
/**
* Clone with parameters merged.
*
* @param array<string, mixed> $params
*/
public function withParamsMerged(array $params): self
{
$obj = clone $this;
/** @var array<string, mixed> $params */
$params = Util::merge($this->params, $params);
$obj->params = $params;
return $obj;
}
/**
* To an associative array.
*
* @return array<string, mixed>
*/
public function toAssoc(): array
{
return $this->params;
}
}

View File

@@ -0,0 +1,155 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils\Database\Orm\Defs;
use Espo\ORM\Defs\Params\EntityParam;
/**
* Immutable.
*/
class EntityDefs
{
/** @var array<string, AttributeDefs> */
private array $attributes = [];
/** @var array<string, RelationDefs> */
private array $relations = [];
/** @var array<string, IndexDefs> */
private array $indexes = [];
private function __construct() {}
public static function create(): self
{
return new self();
}
public function withAttribute(AttributeDefs $attributeDefs): self
{
$obj = clone $this;
$obj->attributes[$attributeDefs->getName()] = $attributeDefs;
return $obj;
}
public function withRelation(RelationDefs $relationDefs): self
{
$obj = clone $this;
$obj->relations[$relationDefs->getName()] = $relationDefs;
return $obj;
}
public function withIndex(IndexDefs $index): self
{
$obj = clone $this;
$obj->indexes[$index->getName()] = $index;
return $obj;
}
public function withoutAttribute(string $name): self
{
$obj = clone $this;
unset($obj->attributes[$name]);
return $obj;
}
public function withoutRelation(string $name): self
{
$obj = clone $this;
unset($obj->relations[$name]);
return $obj;
}
public function withoutIndex(string $name): self
{
$obj = clone $this;
unset($obj->indexes[$name]);
return $obj;
}
public function getAttribute(string $name): ?AttributeDefs
{
return $this->attributes[$name] ?? null;
}
public function getRelation(string $name): ?RelationDefs
{
return $this->relations[$name] ?? null;
}
public function getIndex(string $name): ?IndexDefs
{
return $this->indexes[$name] ?? null;
}
/**
* @return array<string, array<string, mixed>>
*/
public function toAssoc(): array
{
$data = [];
if (count($this->attributes)) {
$attributesData = [];
foreach ($this->attributes as $name => $attributeDefs) {
$attributesData[$name] = $attributeDefs->toAssoc();
}
$data[EntityParam::ATTRIBUTES] = $attributesData;
}
if (count($this->relations)) {
$relationsData = [];
foreach ($this->relations as $name => $relationDefs) {
$relationsData[$name] = $relationDefs->toAssoc();
}
$data[EntityParam::RELATIONS] = $relationsData;
}
if (count($this->indexes)) {
$indexesData = [];
foreach ($this->indexes as $name => $indexDefs) {
$indexesData[$name] = $indexDefs->toAssoc();
}
$data[EntityParam::INDEXES] = $indexesData;
}
return $data;
}
}

View File

@@ -0,0 +1,175 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils\Database\Orm\Defs;
use Espo\Core\Utils\Util;
use Espo\ORM\Defs\Params\IndexParam;
/**
* Immutable.
*/
class IndexDefs
{
/** @var array<string, mixed> */
private array $params = [];
private function __construct(private string $name) {}
public static function create(string $name): self
{
return new self($name);
}
/**
* Get a relation name.
*/
public function getName(): string
{
return $this->name;
}
/**
* Whether a parameter is set.
*/
public function hasParam(string $name): bool
{
return array_key_exists($name, $this->params);
}
/**
* Get a parameter value.
*/
public function getParam(string $name): mixed
{
return $this->params[$name] ?? null;
}
/**
* Clone with a parameter.
*/
public function withParam(string $name, mixed $value): self
{
$obj = clone $this;
$obj->params[$name] = $value;
return $obj;
}
/**
* Clone without a parameter.
*/
public function withoutParam(string $name): self
{
$obj = clone $this;
unset($obj->params[$name]);
return $obj;
}
public function withUnique(): self
{
$obj = clone $this;
$obj->params[IndexParam::TYPE] = 'unique';
return $obj;
}
public function withoutUnique(): self
{
$obj = clone $this;
unset($obj->params[IndexParam::TYPE]);
return $obj;
}
public function withFlag(string $flag): self
{
$obj = clone $this;
$flags = $obj->params[IndexParam::FLAGS] ?? [];
if (!in_array($flag, $flags)) {
$flags[] = $flag;
}
$obj->params[IndexParam::FLAGS] = $flags;
return $obj;
}
public function withoutFlag(string $flag): self
{
$obj = clone $this;
$flags = $obj->params[IndexParam::FLAGS] ?? [];
$index = array_search($flag, $flags, true);
if ($index !== -1) {
unset($flags[$index]);
$flags = array_values($flags);
}
$obj->params[IndexParam::FLAGS] = $flags;
if ($flags === []) {
unset($obj->params[IndexParam::FLAGS]);
}
return $obj;
}
/**
* Clone with parameters merged.
*
* @param array<string, mixed> $params
*/
public function withParamsMerged(array $params): self
{
$obj = clone $this;
/** @var array<string, mixed> $params */
$params = Util::merge($this->params, $params);
$obj->params = $params;
return $obj;
}
/**
* To an associative array.
*
* @return array<string, mixed>
*/
public function toAssoc(): array
{
return $this->params;
}
}

View File

@@ -0,0 +1,256 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils\Database\Orm\Defs;
use Espo\Core\Utils\Util;
use Espo\ORM\Defs\Params\RelationParam;
use Espo\ORM\Type\RelationType;
class RelationDefs
{
/** @var array<string, mixed> */
private array $params = [];
private function __construct(private string $name) {}
public static function create(string $name): self
{
return new self($name);
}
/**
* Get a relation name.
*/
public function getName(): string
{
return $this->name;
}
/**
* Get a type.
*
* @return RelationType::*
*/
public function getType(): ?string
{
/** @var ?RelationType::* */
return $this->getParam(RelationParam::TYPE);
}
/**
* Clone with a type.
*
* @param RelationType::* $type
*/
public function withType(string $type): self
{
return $this->withParam(RelationParam::TYPE, $type);
}
/**
* Clone with a foreign entity type.
*/
public function withForeignEntityType(string $entityType): self
{
return $this->withParam(RelationParam::ENTITY, $entityType);
}
/**
* Get a foreign entity type.
*/
public function getForeignEntityType(): ?string
{
return $this->getParam(RelationParam::ENTITY);
}
/**
* Clone with a foreign relation name.
*/
public function withForeignRelationName(?string $name): self
{
return $this->withParam(RelationParam::FOREIGN, $name);
}
/**
* Get a foreign relation name.
*/
public function getForeignRelationName(): ?string
{
return $this->getParam(RelationParam::FOREIGN);
}
/**
* Clone with a relationship name.
*/
public function withRelationshipName(string $name): self
{
return $this->withParam(RelationParam::RELATION_NAME, $name);
}
/**
* Get a foreign relation name.
*/
public function getRelationshipName(): ?string
{
return $this->getParam(RelationParam::RELATION_NAME);
}
/**
* Clone with a key.
*/
public function withKey(string $key): self
{
return $this->withParam(RelationParam::KEY, $key);
}
/**
* Get a key.
*/
public function getKey(): ?string
{
return $this->getParam(RelationParam::KEY);
}
/**
* Clone with a key.
*/
public function withForeignKey(string $foreignKey): self
{
return $this->withParam(RelationParam::FOREIGN_KEY, $foreignKey);
}
/**
* Get a key.
*/
public function getForeignKey(): ?string
{
return $this->getParam(RelationParam::FOREIGN_KEY);
}
/**
* Clone with middle keys.
*/
public function withMidKeys(string $midKey, string $foreignMidKey): self
{
return $this->withParam(RelationParam::MID_KEYS, [$midKey, $foreignMidKey]);
}
/**
* Whether a parameter is set.
*/
public function hasParam(string $name): bool
{
return array_key_exists($name, $this->params);
}
/**
* Get a parameter value.
*/
public function getParam(string $name): mixed
{
return $this->params[$name] ?? null;
}
/**
* Clone with a parameter.
*/
public function withParam(string $name, mixed $value): self
{
$obj = clone $this;
$obj->params[$name] = $value;
return $obj;
}
/**
* Clone without a parameter.
*/
public function withoutParam(string $name): self
{
$obj = clone $this;
unset($obj->params[$name]);
return $obj;
}
/**
* Clone with conditions. Conditions are used for relationships that share a same middle table.
*
* @param array<string, scalar|(array<int, mixed>)|null> $conditions
*/
public function withConditions(array $conditions): self
{
$obj = clone $this;
return $obj->withParam(RelationParam::CONDITIONS, $conditions);
}
/**
* Clone with an additional middle table column.
*/
public function withAdditionalColumn(AttributeDefs $attributeDefs): self
{
$obj = clone $this;
/** @var array<string, array<string, mixed>> $list */
$list = $obj->getParam(RelationParam::ADDITIONAL_COLUMNS) ?? [];
$list[$attributeDefs->getName()] = $attributeDefs->toAssoc();
return $obj->withParam(RelationParam::ADDITIONAL_COLUMNS, $list);
}
/**
* Clone with parameters merged.
*
* @param array<string, mixed> $params
*/
public function withParamsMerged(array $params): self
{
$obj = clone $this;
/** @var array<string, mixed> $params */
$params = Util::merge($this->params, $params);
$obj->params = $params;
return $obj;
}
/**
* To an associative array.
*
* @return array<string, mixed>
*/
public function toAssoc(): array
{
return $this->params;
}
}

View File

@@ -0,0 +1,41 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils\Database\Orm;
use Espo\ORM\Defs\FieldDefs;
use Espo\Core\Utils\Database\Orm\Defs\EntityDefs;
/**
* Converts field definitions to ORM definitions.
*/
interface FieldConverter
{
public function convert(FieldDefs $fieldDefs, string $entityType): EntityDefs;
}

View File

@@ -0,0 +1,70 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils\Database\Orm\FieldConverters;
use Espo\Core\Name\Field;
use Espo\Core\ORM\Defs\AttributeParam;
use Espo\Core\Utils\Database\Orm\Defs\AttributeDefs;
use Espo\Core\Utils\Database\Orm\Defs\EntityDefs;
use Espo\Core\Utils\Database\Orm\FieldConverter;
use Espo\ORM\Defs\FieldDefs;
use Espo\ORM\Query\Part\Order;
use Espo\ORM\Type\AttributeType;
class AttachmentMultiple implements FieldConverter
{
public function convert(FieldDefs $fieldDefs, string $entityType): EntityDefs
{
$name = $fieldDefs->getName();
return EntityDefs::create()
->withAttribute(
AttributeDefs::create($name . 'Ids')
->withType(AttributeType::JSON_ARRAY)
->withNotStorable()
->withParamsMerged([
'orderBy' => [
[Field::CREATED_AT, Order::ASC],
[Field::NAME, Order::ASC],
],
AttributeParam::IS_LINK_MULTIPLE_ID_LIST => true,
'relation' => $name,
])
)
->withAttribute(
AttributeDefs::create($name . 'Names')
->withType(AttributeType::JSON_OBJECT)
->withNotStorable()
->withParamsMerged([
AttributeParam::IS_LINK_MULTIPLE_NAME_MAP => true,
])
);
}
}

View File

@@ -0,0 +1,350 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils\Database\Orm\FieldConverters;
use Doctrine\DBAL\Types\Types;
use Espo\Core\Currency\ConfigDataProvider;
use Espo\Core\ORM\Type\FieldType;
use Espo\Core\Utils\Config;
use Espo\Core\Utils\Database\Orm\Defs\AttributeDefs;
use Espo\Core\Utils\Database\Orm\Defs\EntityDefs;
use Espo\Core\Utils\Database\Orm\FieldConverter;
use Espo\ORM\Defs\FieldDefs;
use Espo\ORM\Defs\Params\AttributeParam;
use Espo\ORM\Defs\Params\FieldParam;
use Espo\ORM\Query\Part\Expression as Expr;
use Espo\ORM\Type\AttributeType;
class Currency implements FieldConverter
{
private const DEFAULT_PRECISION = 13;
private const DEFAULT_SCALE = 4;
public function __construct(
private Config $config,
private ConfigDataProvider $configDataProvider
) {}
public function convert(FieldDefs $fieldDefs, string $entityType): EntityDefs
{
$name = $fieldDefs->getName();
$amountDefs = AttributeDefs::create($name)
->withType(AttributeType::FLOAT)
->withParamsMerged([
'attributeRole' => 'value',
'fieldType' => FieldType::CURRENCY,
]);
$currencyDefs = AttributeDefs::create($name . 'Currency')
->withType(AttributeType::VARCHAR)
->withParamsMerged([
'attributeRole' => 'currency',
'fieldType' => FieldType::CURRENCY,
]);
$convertedDefs = null;
if ($fieldDefs->getParam(FieldParam::DECIMAL)) {
$dbType = $fieldDefs->getParam(FieldParam::DB_TYPE) ?? Types::DECIMAL;
$precision = $fieldDefs->getParam(FieldParam::PRECISION) ?? self::DEFAULT_PRECISION;
$scale = $fieldDefs->getParam(FieldParam::SCALE) ?? self::DEFAULT_SCALE;
$amountDefs = $amountDefs
->withType(AttributeType::VARCHAR)
->withDbType($dbType)
->withParam(AttributeParam::PRECISION, $precision)
->withParam(AttributeParam::SCALE, $scale);
$defaultValue = $fieldDefs->getParam(AttributeParam::DEFAULT);
if (is_int($defaultValue) || is_float($defaultValue)) {
$defaultValue = number_format($defaultValue, $scale, '.', '');
$amountDefs = $amountDefs->withParam(AttributeParam::DEFAULT, $defaultValue);
}
}
if ($fieldDefs->isNotStorable()) {
$amountDefs = $amountDefs->withNotStorable();
$currencyDefs = $currencyDefs->withNotStorable();
}
if (!$fieldDefs->isNotStorable()) {
[$amountDefs, $convertedDefs] = $this->config->get('currencyNoJoinMode') ?
$this->applyNoJoinMode($fieldDefs, $amountDefs) :
$this->applyJoinMode($fieldDefs, $amountDefs, $entityType);
}
$entityDefs = EntityDefs::create()
->withAttribute($amountDefs)
->withAttribute($currencyDefs);
if ($convertedDefs) {
$entityDefs = $entityDefs->withAttribute($convertedDefs);
}
return $entityDefs;
}
/**
* @return array{AttributeDefs, AttributeDefs}
*/
private function applyNoJoinMode(FieldDefs $fieldDefs, AttributeDefs $amountDefs): array
{
$name = $fieldDefs->getName();
$currencyAttribute = $name . 'Currency';
$defaultCurrency = $this->configDataProvider->getDefaultCurrency();
$baseCurrency = $this->configDataProvider->getBaseCurrency();
$rates = $this->configDataProvider->getCurrencyRates()->toAssoc();
if ($defaultCurrency !== $baseCurrency) {
$rates = $this->exchangeRates($baseCurrency, $defaultCurrency, $rates);
}
$expr = Expr::multiply(
Expr::column($name),
Expr::if(
Expr::equal(Expr::column($currencyAttribute), $defaultCurrency),
1.0,
$this->buildExpression($currencyAttribute, $rates)
)
)->getValue();
$exprForeign = Expr::multiply(
Expr::column("ALIAS.{$name}"),
Expr::if(
Expr::equal(Expr::column("ALIAS.{$name}Currency"), $defaultCurrency),
1.0,
$this->buildExpression("ALIAS.{$name}Currency", $rates)
)
)->getValue();
$exprForeign = str_replace('ALIAS', '{alias}', $exprForeign);
$convertedDefs = AttributeDefs::create($name . 'Converted')
->withType(AttributeType::FLOAT)
->withParamsMerged([
'select' => [
'select' => $expr,
],
'selectForeign' => [
'select' => $exprForeign,
],
'where' => [
"=" => [
'whereClause' => [
$expr . '=' => '{value}',
],
],
">" => [
'whereClause' => [
$expr . '>' => '{value}',
],
],
"<" => [
'whereClause' => [
$expr . '<' => '{value}',
],
],
">=" => [
'whereClause' => [
$expr . '>=' => '{value}',
],
],
"<=" => [
'whereClause' => [
$expr . '<=' => '{value}',
],
],
"<>" => [
'whereClause' => [
$expr . '!=' => '{value}',
],
],
"IS NULL" => [
'whereClause' => [
$expr . '=' => null,
],
],
"IS NOT NULL" => [
'whereClause' => [
$expr . '!=' => null,
],
],
],
AttributeParam::NOT_STORABLE => true,
'order' => [
'order' => [
[$expr, '{direction}'],
],
],
'attributeRole' => 'valueConverted',
'fieldType' => FieldType::CURRENCY,
]);
return [$amountDefs, $convertedDefs];
}
/**
* @param array<string, float> $currencyRates
* @return array<string, float>
*/
private function exchangeRates(string $baseCurrency, string $defaultCurrency, array $currencyRates): array
{
$precision = 5;
$defaultCurrencyRate = round(1 / $currencyRates[$defaultCurrency], $precision);
$exchangedRates = [];
$exchangedRates[$baseCurrency] = $defaultCurrencyRate;
unset($currencyRates[$baseCurrency], $currencyRates[$defaultCurrency]);
foreach ($currencyRates as $currencyName => $rate) {
$exchangedRates[$currencyName] = round($rate * $defaultCurrencyRate, $precision);
}
return $exchangedRates;
}
/**
* @param array<string, float> $rates
*/
private function buildExpression(string $currencyAttribute, array $rates): Expr|float
{
if ($rates === []) {
return 0.0;
}
$currency = array_key_first($rates);
$value = $rates[$currency];
unset($rates[$currency]);
return Expr::if(
Expr::equal(Expr::column($currencyAttribute), $currency),
$value,
$this->buildExpression($currencyAttribute, $rates)
);
}
/**
* @return array{AttributeDefs, AttributeDefs}
*/
private function applyJoinMode(FieldDefs $fieldDefs, AttributeDefs $amountDefs, string $entityType): array
{
$name = $fieldDefs->getName();
$alias = $name . 'CurrencyRate';
$leftJoins = [
[
'Currency',
$alias,
[$alias . '.id:' => $name . 'Currency'],
]
];
$foreignCurrencyAlias = "{$alias}{$entityType}{alias}Foreign";
$mulExpression = "MUL:({$name}, {$alias}.rate)";
$amountDefs = $amountDefs->withParamsMerged([
'order' => [
'order' => [
[$mulExpression, '{direction}'],
],
'leftJoins' => $leftJoins,
'additionalSelect' => ["{$alias}.rate"],
]
]);
$convertedDefs = AttributeDefs::create($name . 'Converted')
->withType(AttributeType::FLOAT)
->withParamsMerged([
'select' => [
'select' => $mulExpression,
'leftJoins' => $leftJoins,
],
'selectForeign' => [
'select' => "MUL:({alias}.{$name}, {$foreignCurrencyAlias}.rate)",
'leftJoins' => [
[
'Currency',
$foreignCurrencyAlias,
[$foreignCurrencyAlias . '.id:' => "{alias}.{$name}Currency"]
]
],
],
'where' => [
"=" => [
'whereClause' => [$mulExpression . '=' => '{value}'],
'leftJoins' => $leftJoins,
],
">" => [
'whereClause' => [$mulExpression . '>' => '{value}'],
'leftJoins' => $leftJoins,
],
"<" => [
'whereClause' => [$mulExpression . '<' => '{value}'],
'leftJoins' => $leftJoins,
],
">=" => [
'whereClause' => [$mulExpression . '>=' => '{value}'],
'leftJoins' => $leftJoins,
],
"<=" => [
'whereClause' => [$mulExpression . '<=' => '{value}'],
'leftJoins' => $leftJoins,
],
"<>" => [
'whereClause' => [$mulExpression . '!=' => '{value}'],
'leftJoins' => $leftJoins,
],
"IS NULL" => [
'whereClause' => [$name . '=' => null],
],
"IS NOT NULL" => [
'whereClause' => [$name . '!=' => null],
],
],
AttributeParam::NOT_STORABLE => true,
'order' => [
'order' => [
[$mulExpression, '{direction}'],
],
'leftJoins' => $leftJoins,
'additionalSelect' => ["{$alias}.rate"],
],
'attributeRole' => 'valueConverted',
'fieldType' => FieldType::CURRENCY,
]);
return [$amountDefs, $convertedDefs];
}
}

View File

@@ -0,0 +1,423 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils\Database\Orm\FieldConverters;
use Espo\Core\ORM\Defs\AttributeParam;
use Espo\Core\ORM\Type\FieldType;
use Espo\Core\Utils\Database\Orm\Defs\AttributeDefs;
use Espo\Core\Utils\Database\Orm\Defs\EntityDefs;
use Espo\Core\Utils\Database\Orm\Defs\RelationDefs;
use Espo\Core\Utils\Database\Orm\FieldConverter;
use Espo\Entities\EmailAddress;
use Espo\ORM\Defs\FieldDefs;
use Espo\ORM\Name\Attribute;
use Espo\ORM\Type\AttributeType;
use Espo\ORM\Type\RelationType;
class Email implements FieldConverter
{
private const COLUMN_ENTITY_TYPE_LENGTH = 100;
public function convert(FieldDefs $fieldDefs, string $entityType): EntityDefs
{
$name = $fieldDefs->getName();
$foreignJoinAlias = "$name$entityType{alias}Foreign";
$foreignJoinMiddleAlias = "$name$entityType{alias}ForeignMiddle";
$emailAddressDefs = AttributeDefs
::create($name)
->withType(AttributeType::VARCHAR)
->withParamsMerged(
$this->getEmailAddressParams($entityType, $foreignJoinAlias, $foreignJoinMiddleAlias)
);
$dataDefs = AttributeDefs
::create($name . 'Data')
->withType(AttributeType::JSON_ARRAY)
->withNotStorable()
->withParamsMerged([
AttributeParam::NOT_EXPORTABLE => true,
'isEmailAddressData' => true,
'field' => $name,
]);
$isOptedOutDefs = AttributeDefs
::create($name . 'IsOptedOut')
->withType(AttributeType::BOOL)
->withNotStorable()
->withParamsMerged(
$this->getIsOptedOutParams($foreignJoinAlias, $foreignJoinMiddleAlias)
);
$isInvalidDefs = AttributeDefs
::create($name . 'IsInvalid')
->withType(AttributeType::BOOL)
->withNotStorable()
->withParamsMerged(
$this->getIsInvalidParams($foreignJoinAlias, $foreignJoinMiddleAlias)
);
$relationDefs = RelationDefs
::create('emailAddresses')
->withType(RelationType::MANY_MANY)
->withForeignEntityType(EmailAddress::ENTITY_TYPE)
->withRelationshipName('entityEmailAddress')
->withMidKeys('entityId', 'emailAddressId')
->withConditions(['entityType' => $entityType])
->withAdditionalColumn(
AttributeDefs
::create('entityType')
->withType(AttributeType::VARCHAR)
->withLength(self::COLUMN_ENTITY_TYPE_LENGTH)
)
->withAdditionalColumn(
AttributeDefs
::create('primary')
->withType(AttributeType::BOOL)
->withDefault(false)
);
return EntityDefs::create()
->withAttribute($emailAddressDefs)
->withAttribute($dataDefs)
->withAttribute($isOptedOutDefs)
->withAttribute($isInvalidDefs)
->withRelation($relationDefs);
}
/**
* @return array<string, mixed>
*/
private function getEmailAddressParams(
string $entityType,
string $foreignJoinAlias,
string $foreignJoinMiddleAlias,
): array {
return [
'select' => [
"select" => "emailAddresses.name",
'leftJoins' => [['emailAddresses', 'emailAddresses', ['primary' => true]]],
],
'selectForeign' => [
"select" => "$foreignJoinAlias.name",
'leftJoins' => [
[
'EntityEmailAddress',
$foreignJoinMiddleAlias,
[
"$foreignJoinMiddleAlias.entityId:" => "{alias}.id",
"$foreignJoinMiddleAlias.primary" => true,
"$foreignJoinMiddleAlias.deleted" => false,
]
],
[
EmailAddress::ENTITY_TYPE,
$foreignJoinAlias,
[
"$foreignJoinAlias.id:" => "$foreignJoinMiddleAlias.emailAddressId",
"$foreignJoinAlias.deleted" => false,
]
]
],
],
'fieldType' => FieldType::EMAIL,
'where' => [
'LIKE' => [
'whereClause' => [
'id=s' => [
'from' => 'EntityEmailAddress',
'select' => ['entityId'],
'joins' => [
[
'emailAddress',
'emailAddress',
[
'emailAddress.id:' => 'emailAddressId',
'emailAddress.deleted' => false,
],
]
],
'whereClause' => [
Attribute::DELETED => false,
'entityType' => $entityType,
"LIKE:(emailAddress.lower, LOWER:({value})):" => null,
],
],
],
],
'NOT LIKE' => [
'whereClause' => [
'id!=s' => [
'from' => 'EntityEmailAddress',
'select' => ['entityId'],
'joins' => [
[
'emailAddress',
'emailAddress',
[
'emailAddress.id:' => 'emailAddressId',
'emailAddress.deleted' => false,
],
]
],
'whereClause' => [
Attribute::DELETED => false,
'entityType' => $entityType,
"LIKE:(emailAddress.lower, LOWER:({value})):" => null,
],
],
],
],
'=' => [
'leftJoins' => [['emailAddresses', 'emailAddressesMultiple']],
'whereClause' => [
"EQUAL:(emailAddressesMultiple.lower, LOWER:({value})):" => null,
]
],
'<>' => [
'whereClause' => [
'id!=s' => [
'from' => 'EntityEmailAddress',
'select' => ['entityId'],
'joins' => [
[
'emailAddress',
'emailAddress',
[
'emailAddress.id:' => 'emailAddressId',
'emailAddress.deleted' => false,
],
]
],
'whereClause' => [
Attribute::DELETED => false,
'entityType' => $entityType,
"EQUAL:(emailAddress.lower, LOWER:({value})):" => null,
],
],
],
],
'IN' => [
'whereClause' => [
'id=s' => [
'from' => 'EntityEmailAddress',
'select' => ['entityId'],
'joins' => [
[
'emailAddress',
'emailAddress',
[
'emailAddress.id:' => 'emailAddressId',
'emailAddress.deleted' => false,
],
]
],
'whereClause' => [
Attribute::DELETED => false,
'entityType' => $entityType,
"emailAddress.lower" => '{value}',
],
],
],
],
'NOT IN' => [
'whereClause' => [
'id!=s' => [
'from' => 'EntityEmailAddress',
'select' => ['entityId'],
'joins' => [
[
'emailAddress',
'emailAddress',
[
'emailAddress.id:' => 'emailAddressId',
'emailAddress.deleted' => false,
],
]
],
'whereClause' => [
Attribute::DELETED => false,
'entityType' => $entityType,
"emailAddress.lower" => '{value}',
],
],
],
],
'IS NULL' => [
'leftJoins' => [['emailAddresses', 'emailAddressesMultiple']],
'whereClause' => [
'emailAddressesMultiple.lower=' => null,
]
],
'IS NOT NULL' => [
'whereClause' => [
'id=s' => [
'from' => 'EntityEmailAddress',
'select' => ['entityId'],
'whereClause' => [
Attribute::DELETED => false,
'entityType' => $entityType,
],
],
],
],
],
'order' => [
'order' => [
['emailAddresses.lower', '{direction}'],
],
'leftJoins' => [['emailAddresses', 'emailAddresses', ['primary' => true]]],
'additionalSelect' => ['emailAddresses.lower'],
],
];
}
/**
* @return array<string, mixed>
*/
private function getIsOptedOutParams(string $foreignJoinAlias, string $foreignJoinMiddleAlias): array
{
return [
'select' => [
'select' => "emailAddresses.optOut",
'leftJoins' => [['emailAddresses', 'emailAddresses', ['primary' => true]]],
],
'selectForeign' => [
'select' => "$foreignJoinAlias.optOut",
'leftJoins' => [
[
'EntityEmailAddress',
$foreignJoinMiddleAlias,
[
"$foreignJoinMiddleAlias.entityId:" => "{alias}.id",
"$foreignJoinMiddleAlias.primary" => true,
"$foreignJoinMiddleAlias.deleted" => false,
]
],
[
EmailAddress::ENTITY_TYPE,
$foreignJoinAlias,
[
"$foreignJoinAlias.id:" => "$foreignJoinMiddleAlias.emailAddressId",
"$foreignJoinAlias.deleted" => false,
]
]
],
],
'where' => [
'= TRUE' => [
'whereClause' => [
['emailAddresses.optOut=' => true],
['emailAddresses.optOut!=' => null],
],
'leftJoins' => [['emailAddresses', 'emailAddresses', ['primary' => true]]],
],
'= FALSE' => [
'whereClause' => [
'OR' => [
['emailAddresses.optOut=' => false],
['emailAddresses.optOut=' => null],
]
],
'leftJoins' => [['emailAddresses', 'emailAddresses', ['primary' => true]]],
]
],
'order' => [
'order' => [
['emailAddresses.optOut', '{direction}'],
],
'leftJoins' => [['emailAddresses', 'emailAddresses', ['primary' => true]]],
'additionalSelect' => ['emailAddresses.optOut'],
],
];
}
/**
* @return array<string, mixed>
*/
private function getIsInvalidParams(string $foreignJoinAlias, string $foreignJoinMiddleAlias): array
{
return [
'select' => [
'select' => "emailAddresses.invalid",
'leftJoins' => [['emailAddresses', 'emailAddresses', ['primary' => true]]],
],
'selectForeign' => [
'select' => "$foreignJoinAlias.invalid",
'leftJoins' => [
[
'EntityEmailAddress',
$foreignJoinMiddleAlias,
[
"$foreignJoinMiddleAlias.entityId:" => "{alias}.id",
"$foreignJoinMiddleAlias.primary" => true,
"$foreignJoinMiddleAlias.deleted" => false,
]
],
[
EmailAddress::ENTITY_TYPE,
$foreignJoinAlias,
[
"$foreignJoinAlias.id:" => "$foreignJoinMiddleAlias.emailAddressId",
"$foreignJoinAlias.deleted" => false,
]
]
],
],
'where' => [
'= TRUE' => [
'whereClause' => [
['emailAddresses.invalid=' => true],
['emailAddresses.invalid!=' => null],
],
'leftJoins' => [['emailAddresses', 'emailAddresses', ['primary' => true]]],
],
'= FALSE' => [
'whereClause' => [
'OR' => [
['emailAddresses.invalid=' => false],
['emailAddresses.invalid=' => null],
]
],
'leftJoins' => [['emailAddresses', 'emailAddresses', ['primary' => true]]],
]
],
'order' => [
'order' => [
['emailAddresses.invalid', '{direction}'],
],
'leftJoins' => [['emailAddresses', 'emailAddresses', ['primary' => true]]],
'additionalSelect' => ['emailAddresses.invalid'],
],
];
}
}

View File

@@ -0,0 +1,100 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils\Database\Orm\FieldConverters;
use Espo\Core\Name\Field;
use Espo\Core\Utils\Database\Orm\Defs\AttributeDefs;
use Espo\Core\Utils\Database\Orm\Defs\EntityDefs;
use Espo\Core\Utils\Database\Orm\Defs\RelationDefs;
use Espo\Core\Utils\Database\Orm\FieldConverter;
use Espo\Entities\Attachment;
use Espo\ORM\Defs\FieldDefs;
use Espo\ORM\Defs\Params\AttributeParam;
use Espo\ORM\Defs\Params\RelationParam;
use Espo\ORM\Name\Attribute;
use Espo\ORM\Type\AttributeType;
use Espo\ORM\Type\RelationType;
class File implements FieldConverter
{
public function convert(FieldDefs $fieldDefs, string $entityType): EntityDefs
{
$name = $fieldDefs->getName();
$idName = $name . 'Id';
$nameName = $name . 'Name';
$idDefs = AttributeDefs::create($idName)
->withType(AttributeType::FOREIGN_ID)
->withParam('index', false);
$nameDefs = AttributeDefs::create($nameName)
->withType(AttributeType::FOREIGN);
if ($fieldDefs->isNotStorable()) {
$idDefs = $idDefs->withNotStorable();
$nameDefs = $nameDefs->withType(AttributeType::VARCHAR);
}
/** @var array<string, mixed> $defaults */
$defaults = $fieldDefs->getParam('defaultAttributes') ?? [];
if (array_key_exists($idName, $defaults)) {
$idDefs = $idDefs->withDefault($defaults[$idName]);
}
$relationDefs = null;
if (!$fieldDefs->isNotStorable()) {
$nameDefs = $nameDefs->withParamsMerged([
AttributeParam::RELATION => $name,
AttributeParam::FOREIGN => Field::NAME,
]);
$relationDefs = RelationDefs::create($name)
->withType(RelationType::BELONGS_TO)
->withForeignEntityType(Attachment::ENTITY_TYPE)
->withKey($idName)
->withForeignKey(Attribute::ID)
->withParam(RelationParam::FOREIGN, null);
}
$entityDefs = EntityDefs::create()
->withAttribute($idDefs)
->withAttribute($nameDefs);
if ($relationDefs) {
$entityDefs = $entityDefs->withRelation($relationDefs);
}
return $entityDefs;
}
}

View File

@@ -0,0 +1,79 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils\Database\Orm\FieldConverters;
use Espo\Core\ORM\Type\FieldType;
use Espo\Core\Utils\Database\Orm\Defs\AttributeDefs;
use Espo\Core\Utils\Database\Orm\Defs\EntityDefs;
use Espo\Core\Utils\Database\Orm\FieldConverter;
use Espo\ORM\Defs\FieldDefs;
use Espo\ORM\Type\AttributeType;
class Link implements FieldConverter
{
public function convert(FieldDefs $fieldDefs, string $entityType): EntityDefs
{
$name = $fieldDefs->getName();
$idName = $name . 'Id';
$nameName = $name . 'Name';
$idDefs = AttributeDefs::create($idName)
->withType(AttributeType::FOREIGN_ID)
->withParamsMerged([
'index' => $name,
'attributeRole' => 'id',
'fieldType' => FieldType::LINK,
]);
$nameDefs = AttributeDefs::create($nameName)
->withType(AttributeType::VARCHAR)
->withNotStorable()
->withParamsMerged([
'attributeRole' => 'name',
'fieldType' => FieldType::LINK,
]);
if ($fieldDefs->isNotStorable()) {
$idDefs = $idDefs->withNotStorable();
}
/** @var array<string, mixed> $defaults */
$defaults = $fieldDefs->getParam('defaultAttributes') ?? [];
if (array_key_exists($idName, $defaults)) {
$idDefs = $idDefs->withDefault($defaults[$idName]);
}
return EntityDefs::create()
->withAttribute($idDefs)
->withAttribute($nameDefs);
}
}

View File

@@ -0,0 +1,110 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils\Database\Orm\FieldConverters;
use Espo\Core\ORM\Defs\AttributeParam;
use Espo\Core\ORM\Type\FieldType;
use Espo\Core\Utils\Database\Orm\Defs\AttributeDefs;
use Espo\Core\Utils\Database\Orm\Defs\EntityDefs;
use Espo\Core\Utils\Database\Orm\FieldConverter;
use Espo\ORM\Defs\FieldDefs;
use Espo\ORM\Type\AttributeType;
class LinkMultiple implements FieldConverter
{
public function convert(FieldDefs $fieldDefs, string $entityType): EntityDefs
{
$name = $fieldDefs->getName();
$idsName = $name . 'Ids';
$namesName = $name . 'Names';
$columnsName = $name . 'Columns';
$idsDefs = AttributeDefs::create($idsName)
->withType(AttributeType::JSON_ARRAY)
->withNotStorable()
->withParamsMerged([
AttributeParam::IS_LINK_MULTIPLE_ID_LIST => true,
'relation' => $name,
'isUnordered' => true,
'attributeRole' => 'idList',
'fieldType' => FieldType::LINK_MULTIPLE,
]);
/** @var array<string, mixed> $defaults */
$defaults = $fieldDefs->getParam('defaultAttributes') ?? [];
if (array_key_exists($idsName, $defaults)) {
$idsDefs = $idsDefs->withDefault($defaults[$idsName]);
}
$namesDefs = AttributeDefs::create($namesName)
->withType(AttributeType::JSON_OBJECT)
->withNotStorable()
->withParamsMerged([
AttributeParam::IS_LINK_MULTIPLE_NAME_MAP => true,
'attributeRole' => 'nameMap',
'fieldType' => FieldType::LINK_MULTIPLE,
]);
$orderBy = $fieldDefs->getParam('orderBy');
$orderDirection = $fieldDefs->getParam('orderDirection');
if ($orderBy) {
$idsDefs = $idsDefs->withParam('orderBy', $orderBy);
if ($orderDirection !== null) {
$idsDefs = $idsDefs->withParam('orderDirection', $orderDirection);
}
}
$columns = $fieldDefs->getParam('columns');
$columnsDefs = $columns ?
AttributeDefs::create($columnsName)
->withType(AttributeType::JSON_OBJECT)
->withNotStorable()
->withParamsMerged([
'columns' => $columns,
'attributeRole' => 'columnsMap',
])
: null;
$entityDefs = EntityDefs::create()
->withAttribute($idsDefs)
->withAttribute($namesDefs);
if ($columnsDefs) {
$entityDefs = $entityDefs->withAttribute($columnsDefs);
}
return $entityDefs;
}
}

View File

@@ -0,0 +1,65 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils\Database\Orm\FieldConverters;
use Espo\Core\ORM\Type\FieldType;
use Espo\Core\Utils\Database\Orm\Defs\AttributeDefs;
use Espo\Core\Utils\Database\Orm\Defs\EntityDefs;
use Espo\Core\Utils\Database\Orm\FieldConverter;
use Espo\ORM\Defs\FieldDefs;
use Espo\ORM\Type\AttributeType;
class LinkOne implements FieldConverter
{
public function convert(FieldDefs $fieldDefs, string $entityType): EntityDefs
{
$name = $fieldDefs->getName();
return EntityDefs::create()
->withAttribute(
AttributeDefs::create($name . 'Id')
->withType(AttributeType::VARCHAR)
->withNotStorable()
->withParamsMerged([
'attributeRole' => 'id',
'fieldType' => FieldType::LINK_ONE,
])
)
->withAttribute(
AttributeDefs::create($name . 'Name')
->withType(AttributeType::VARCHAR)
->withNotStorable()
->withParamsMerged([
'attributeRole' => 'name',
'fieldType' => FieldType::LINK_ONE,
])
);
}
}

View File

@@ -0,0 +1,101 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils\Database\Orm\FieldConverters;
use Espo\Core\ORM\Type\FieldType;
use Espo\Core\Utils\Database\Orm\Defs\AttributeDefs;
use Espo\Core\Utils\Database\Orm\Defs\EntityDefs;
use Espo\Core\Utils\Database\Orm\FieldConverter;
use Espo\ORM\Defs\FieldDefs;
use Espo\ORM\Defs\Params\AttributeParam;
use Espo\ORM\Type\AttributeType;
class LinkParent implements FieldConverter
{
private const TYPE_LENGTH = 100;
public function convert(FieldDefs $fieldDefs, string $entityType): EntityDefs
{
$name = $fieldDefs->getName();
$idName = $name . 'Id';
$typeName = $name . 'Type';
$nameName = $name . 'Name';
$idDefs = AttributeDefs::create($idName)
->withType(AttributeType::FOREIGN_ID)
->withParamsMerged([
'index' => $name,
'attributeRole' => 'id',
'fieldType' => FieldType::LINK_PARENT,
]);
$typeDefs = AttributeDefs::create($typeName)
->withType(AttributeType::FOREIGN_TYPE)
->withParam(AttributeParam::NOT_NULL, false)
->withParam('index', $name)
->withLength(self::TYPE_LENGTH)
->withParamsMerged([
'attributeRole' => 'type',
'fieldType' => FieldType::LINK_PARENT,
]);
$nameDefs = AttributeDefs::create($nameName)
->withType(AttributeType::VARCHAR)
->withNotStorable()
->withParamsMerged([
AttributeParam::RELATION => $name,
'isParentName' => true,
'attributeRole' => 'name',
'fieldType' => FieldType::LINK_PARENT,
]);
if ($fieldDefs->isNotStorable()) {
$idDefs = $idDefs->withNotStorable();
$typeDefs = $typeDefs->withNotStorable();
}
/** @var array<string, mixed> $defaults */
$defaults = $fieldDefs->getParam('defaultAttributes') ?? [];
if (array_key_exists($idName, $defaults)) {
$idDefs = $idDefs->withDefault($defaults[$idName]);
}
if (array_key_exists($typeName, $defaults)) {
$typeDefs = $idDefs->withDefault($defaults[$typeName]);
}
return EntityDefs::create()
->withAttribute($idDefs)
->withAttribute($typeDefs)
->withAttribute($nameDefs);
}
}

View File

@@ -0,0 +1,187 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils\Database\Orm\FieldConverters;
use Espo\Core\Utils\Config;
use Espo\Core\Utils\Database\Orm\Defs\AttributeDefs;
use Espo\Core\Utils\Database\Orm\Defs\EntityDefs;
use Espo\Core\Utils\Database\Orm\FieldConverter;
use Espo\ORM\Defs\FieldDefs;
use Espo\ORM\Defs\Params\AttributeParam;
use Espo\ORM\Defs\Params\FieldParam;
use Espo\ORM\Type\AttributeType;
/**
* @noinspection PhpUnused
*/
class PersonName implements FieldConverter
{
private const FORMAT_LAST_FIRST = 'lastFirst';
private const FORMAT_LAST_FIRST_MIDDLE = 'lastFirstMiddle';
private const FORMAT_FIRST_MIDDLE_LAST = 'firstMiddleLast';
public function __construct(private Config $config) {}
public function convert(FieldDefs $fieldDefs, string $entityType): EntityDefs
{
$format = $this->config->get('personNameFormat');
$name = $fieldDefs->getName();
$firstName = 'first' . ucfirst($name);
$lastName = 'last' . ucfirst($name);
$middleName = 'middle' . ucfirst($name);
$subList = match ($format) {
self::FORMAT_LAST_FIRST => [$lastName, ' ', $firstName],
self::FORMAT_LAST_FIRST_MIDDLE => [$lastName, ' ', $firstName, ' ', $middleName],
self::FORMAT_FIRST_MIDDLE_LAST => [$firstName, ' ', $middleName, ' ', $lastName],
default => [$firstName, ' ', $lastName],
};
if (
$format === self::FORMAT_LAST_FIRST_MIDDLE ||
$format === self::FORMAT_LAST_FIRST
) {
$orderBy1Field = $lastName;
$orderBy2Field = $firstName;
} else {
$orderBy1Field = $firstName;
$orderBy2Field = $lastName;
}
$fullList = [];
$whereItems = [];
foreach ($subList as $subFieldName) {
$fieldNameTrimmed = trim($subFieldName);
if (empty($fieldNameTrimmed)) {
$fullList[] = "'" . $subFieldName . "'";
continue;
}
$fullList[] = $fieldNameTrimmed;
$whereItems[] = $fieldNameTrimmed;
}
$whereItems[] = "CONCAT:($firstName, ' ', $lastName)";
$whereItems[] = "CONCAT:($lastName, ' ', $firstName)";
if ($format === self::FORMAT_FIRST_MIDDLE_LAST) {
$whereItems[] = "CONCAT:($firstName, ' ', $middleName, ' ', $lastName)";
} else if ($format === self::FORMAT_LAST_FIRST_MIDDLE) {
$whereItems[] = "CONCAT:($lastName, ' ', $firstName, ' ', $middleName)";
}
$selectExpression = $this->getSelect($fullList);
$selectForeignExpression = $this->getSelect($fullList, '{alias}');
if (
$format === self::FORMAT_FIRST_MIDDLE_LAST ||
$format === self::FORMAT_LAST_FIRST_MIDDLE
) {
$selectExpression = "REPLACE:($selectExpression, ' ', ' ')";
$selectForeignExpression = "REPLACE:($selectForeignExpression, ' ', ' ')";
}
$attributeDefs = AttributeDefs::create($name)
->withType(AttributeType::VARCHAR)
->withNotStorable()
->withParamsMerged([
'select' => [
'select' => $selectExpression,
],
'selectForeign' => [
'select' => $selectForeignExpression,
],
'where' => [
'LIKE' => [
'whereClause' => [
'OR' => array_fill_keys(
array_map(fn ($item) => $item . '*', $whereItems),
'{value}'
),
],
],
'NOT LIKE' => [
'whereClause' => [
'AND' => array_fill_keys(
array_map(fn ($item) => $item . '!*', $whereItems),
'{value}'
),
],
],
'=' => [
'whereClause' => [
'OR' => array_fill_keys($whereItems, '{value}'),
],
],
],
'order' => [
'order' => [
[$orderBy1Field, '{direction}'],
[$orderBy2Field, '{direction}'],
],
],
]);
$dependeeAttributeList = $fieldDefs->getParam(FieldParam::DEPENDEE_ATTRIBUTE_LIST);
if ($dependeeAttributeList) {
$attributeDefs = $attributeDefs->withParam(AttributeParam::DEPENDEE_ATTRIBUTE_LIST, $dependeeAttributeList);
}
return EntityDefs::create()
->withAttribute($attributeDefs);
}
/**
* @param string[] $fullList
*/
private function getSelect(array $fullList, ?string $alias = null): string
{
foreach ($fullList as &$item) {
$rowItem = trim($item, " '");
if (empty($rowItem)) {
continue;
}
if ($alias) {
$item = $alias . '.' . $item;
}
$item = "IFNULL:($item, '')";
}
return "NULLIF:(TRIM:(CONCAT:(" . implode(", ", $fullList) . ")), '')";
}
}

View File

@@ -0,0 +1,603 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils\Database\Orm\FieldConverters;
use Espo\Core\ORM\Defs\AttributeParam;
use Espo\Core\ORM\Type\FieldType;
use Espo\Core\Utils\Database\Orm\Defs\AttributeDefs;
use Espo\Core\Utils\Database\Orm\Defs\EntityDefs;
use Espo\Core\Utils\Database\Orm\Defs\RelationDefs;
use Espo\Core\Utils\Database\Orm\FieldConverter;
use Espo\Entities\PhoneNumber;
use Espo\ORM\Defs\FieldDefs;
use Espo\ORM\Name\Attribute;
use Espo\ORM\Type\AttributeType;
use Espo\ORM\Type\RelationType;
/**
* @noinspection PhpUnused
*/
class Phone implements FieldConverter
{
private const COLUMN_ENTITY_TYPE_LENGTH = 100;
public function convert(FieldDefs $fieldDefs, string $entityType): EntityDefs
{
$name = $fieldDefs->getName();
$foreignJoinAlias = "$name$entityType{alias}Foreign";
$foreignJoinMiddleAlias = "$name$entityType{alias}ForeignMiddle";
$emailAddressDefs = AttributeDefs
::create($name)
->withType(AttributeType::VARCHAR)
->withParamsMerged(
$this->getPhoneNumberParams($entityType, $foreignJoinAlias, $foreignJoinMiddleAlias)
);
$dataDefs = AttributeDefs
::create($name . 'Data')
->withType(AttributeType::JSON_ARRAY)
->withNotStorable()
->withParamsMerged([
AttributeParam::NOT_EXPORTABLE => true,
'isPhoneNumberData' => true,
'field' => $name,
]);
$isOptedOutDefs = AttributeDefs
::create($name . 'IsOptedOut')
->withType(AttributeType::BOOL)
->withNotStorable()
->withParamsMerged(
$this->getIsOptedOutParams($foreignJoinAlias, $foreignJoinMiddleAlias)
);
$isInvalidDefs = AttributeDefs
::create($name . 'IsInvalid')
->withType(AttributeType::BOOL)
->withNotStorable()
->withParamsMerged(
$this->getIsInvalidParams($foreignJoinAlias, $foreignJoinMiddleAlias)
);
$numericAttribute = AttributeDefs
::create($name . 'Numeric')
->withType(AttributeType::VARCHAR)
->withNotStorable()
->withParamsMerged(
$this->getNumericParams($entityType)
);
$relationDefs = RelationDefs
::create('phoneNumbers')
->withType(RelationType::MANY_MANY)
->withForeignEntityType(PhoneNumber::ENTITY_TYPE)
->withRelationshipName('entityPhoneNumber')
->withMidKeys('entityId', 'phoneNumberId')
->withConditions(['entityType' => $entityType])
->withAdditionalColumn(
AttributeDefs
::create('entityType')
->withType(AttributeType::VARCHAR)
->withLength(self::COLUMN_ENTITY_TYPE_LENGTH)
)
->withAdditionalColumn(
AttributeDefs
::create('primary')
->withType(AttributeType::BOOL)
->withDefault(false)
);
return EntityDefs::create()
->withAttribute($emailAddressDefs)
->withAttribute($dataDefs)
->withAttribute($isOptedOutDefs)
->withAttribute($isInvalidDefs)
->withAttribute($numericAttribute)
->withRelation($relationDefs);
}
/**
* @return array<string, mixed>
*/
private function getPhoneNumberParams(
string $entityType,
string $foreignJoinAlias,
string $foreignJoinMiddleAlias,
): array {
return [
'select' => [
"select" => "phoneNumbers.name",
'leftJoins' => [['phoneNumbers', 'phoneNumbers', ['primary' => true]]],
],
'selectForeign' => [
"select" => "$foreignJoinAlias.name",
'leftJoins' => [
[
'EntityPhoneNumber',
$foreignJoinMiddleAlias,
[
"$foreignJoinMiddleAlias.entityId:" => "{alias}.id",
"$foreignJoinMiddleAlias.primary" => true,
"$foreignJoinMiddleAlias.deleted" => false,
]
],
[
PhoneNumber::ENTITY_TYPE,
$foreignJoinAlias,
[
"$foreignJoinAlias.id:" => "$foreignJoinMiddleAlias.phoneNumberId",
"$foreignJoinAlias.deleted" => false,
]
]
],
],
'fieldType' => FieldType::PHONE,
'where' => [
'LIKE' => [
'whereClause' => [
'id=s' => [
'from' => 'EntityPhoneNumber',
'select' => ['entityId'],
'joins' => [
[
'phoneNumber',
'phoneNumber',
[
'phoneNumber.id:' => 'phoneNumberId',
'phoneNumber.deleted' => false,
],
],
],
'whereClause' => [
Attribute::DELETED => false,
'entityType' => $entityType,
'phoneNumber.name*' => '{value}',
],
],
],
],
'NOT LIKE' => [
'whereClause' => [
'id!=s' => [
'from' => 'EntityPhoneNumber',
'select' => ['entityId'],
'joins' => [
[
'phoneNumber',
'phoneNumber',
[
'phoneNumber.id:' => 'phoneNumberId',
'phoneNumber.deleted' => false,
],
],
],
'whereClause' => [
Attribute::DELETED => false,
'entityType' => $entityType,
'phoneNumber.name*' => '{value}',
],
],
],
],
'=' => [
'leftJoins' => [['phoneNumbers', 'phoneNumbersMultiple']],
'whereClause' => [
'phoneNumbersMultiple.name=' => '{value}',
]
],
'<>' => [
'whereClause' => [
'id!=s' => [
'from' => 'EntityPhoneNumber',
'select' => ['entityId'],
'joins' => [
[
'phoneNumber',
'phoneNumber',
[
'phoneNumber.id:' => 'phoneNumberId',
'phoneNumber.deleted' => false,
],
],
],
'whereClause' => [
Attribute::DELETED => false,
'entityType' => $entityType,
'phoneNumber.name' => '{value}',
],
],
],
],
'IN' => [
'whereClause' => [
'id=s' => [
'from' => 'EntityPhoneNumber',
'select' => ['entityId'],
'joins' => [
[
'phoneNumber',
'phoneNumber',
[
'phoneNumber.id:' => 'phoneNumberId',
'phoneNumber.deleted' => false,
],
],
],
'whereClause' => [
Attribute::DELETED => false,
'entityType' => $entityType,
'phoneNumber.name' => '{value}',
],
],
],
],
'NOT IN' => [
'whereClause' => [
'id=s' => [
'from' => 'EntityPhoneNumber',
'select' => ['entityId'],
'joins' => [
[
'phoneNumber',
'phoneNumber',
[
'phoneNumber.id:' => 'phoneNumberId',
'phoneNumber.deleted' => false,
],
],
],
'whereClause' => [
Attribute::DELETED => false,
'entityType' => $entityType,
'phoneNumber.name!=' => '{value}',
],
],
],
],
'IS NULL' => [
'leftJoins' => [['phoneNumbers', 'phoneNumbersMultiple']],
'whereClause' => [
'phoneNumbersMultiple.name=' => null,
]
],
'IS NOT NULL' => [
'whereClause' => [
'id=s' => [
'from' => 'EntityPhoneNumber',
'select' => ['entityId'],
'whereClause' => [
Attribute::DELETED => false,
'entityType' => $entityType,
],
],
],
],
],
'order' => [
'order' => [
['phoneNumbers.name', '{direction}'],
],
'leftJoins' => [['phoneNumbers', 'phoneNumbers', ['primary' => true]]],
'additionalSelect' => ['phoneNumbers.name'],
],
];
}
/**
* @return array<string, mixed>
*/
private function getIsOptedOutParams(string $foreignJoinAlias, string $foreignJoinMiddleAlias): array
{
return [
'select' => [
'select' => 'phoneNumbers.optOut',
'leftJoins' => [['phoneNumbers', 'phoneNumbers', ['primary' => true]]],
],
'selectForeign' => [
'select' => "$foreignJoinAlias.optOut",
'leftJoins' => [
[
'EntityPhoneNumber',
$foreignJoinMiddleAlias,
[
"$foreignJoinMiddleAlias.entityId:" => "{alias}.id",
"$foreignJoinMiddleAlias.primary" => true,
"$foreignJoinMiddleAlias.deleted" => false,
]
],
[
PhoneNumber::ENTITY_TYPE,
$foreignJoinAlias,
[
"$foreignJoinAlias.id:" => "$foreignJoinMiddleAlias.phoneNumberId",
"$foreignJoinAlias.deleted" => false,
]
]
],
],
'where' => [
'= TRUE' => [
'whereClause' => [
['phoneNumbers.optOut=' => true],
['phoneNumbers.optOut!=' => null],
],
'leftJoins' => [['phoneNumbers', 'phoneNumbers', ['primary' => true]]],
],
'= FALSE' => [
'whereClause' => [
'OR' => [
['phoneNumbers.optOut=' => false],
['phoneNumbers.optOut=' => null],
]
],
'leftJoins' => [['phoneNumbers', 'phoneNumbers', ['primary' => true]]],
]
],
'order' => [
'order' => [
['phoneNumbers.optOut', '{direction}'],
],
'leftJoins' => [['phoneNumbers', 'phoneNumbers', ['primary' => true]]],
'additionalSelect' => ['phoneNumbers.optOut'],
],
];
}
/**
* @return array<string, mixed>
*/
private function getIsInvalidParams(string $foreignJoinAlias, string $foreignJoinMiddleAlias): array
{
return [
'select' => [
'select' => 'phoneNumbers.invalid',
'leftJoins' => [['phoneNumbers', 'phoneNumbers', ['primary' => true]]],
],
'selectForeign' => [
'select' => "$foreignJoinAlias.invalid",
'leftJoins' => [
[
'EntityPhoneNumber',
$foreignJoinMiddleAlias,
[
"$foreignJoinMiddleAlias.entityId:" => "{alias}.id",
"$foreignJoinMiddleAlias.primary" => true,
"$foreignJoinMiddleAlias.deleted" => false,
]
],
[
PhoneNumber::ENTITY_TYPE,
$foreignJoinAlias,
[
"$foreignJoinAlias.id:" => "$foreignJoinMiddleAlias.phoneNumberId",
"$foreignJoinAlias.deleted" => false,
]
]
],
],
'where' => [
'= TRUE' => [
'whereClause' => [
['phoneNumbers.invalid=' => true],
['phoneNumbers.invalid!=' => null],
],
'leftJoins' => [['phoneNumbers', 'phoneNumbers', ['primary' => true]]],
],
'= FALSE' => [
'whereClause' => [
'OR' => [
['phoneNumbers.invalid=' => false],
['phoneNumbers.invalid=' => null],
]
],
'leftJoins' => [['phoneNumbers', 'phoneNumbers', ['primary' => true]]],
]
],
'order' => [
'order' => [
['phoneNumbers.invalid', '{direction}'],
],
'leftJoins' => [['phoneNumbers', 'phoneNumbers', ['primary' => true]]],
'additionalSelect' => ['phoneNumbers.invalid'],
],
];
}
/**
* @return array<string, mixed>
*/
private function getNumericParams(string $entityType): array
{
return [
AttributeParam::NOT_EXPORTABLE => true,
'where' => [
'LIKE' => [
'whereClause' => [
'id=s' => [
'from' => 'EntityPhoneNumber',
'select' => ['entityId'],
'joins' => [
[
'phoneNumber',
'phoneNumber',
[
'phoneNumber.id:' => 'phoneNumberId',
'phoneNumber.deleted' => false,
],
],
],
'whereClause' => [
Attribute::DELETED => false,
'entityType' => $entityType,
'phoneNumber.numeric*' => '{value}',
],
],
],
],
'NOT LIKE' => [
'whereClause' => [
'id!=s' => [
'from' => 'EntityPhoneNumber',
'select' => ['entityId'],
'joins' => [
[
'phoneNumber',
'phoneNumber',
[
'phoneNumber.id:' => 'phoneNumberId',
'phoneNumber.deleted' => false,
],
]
],
'whereClause' => [
Attribute::DELETED => false,
'entityType' => $entityType,
'phoneNumber.numeric*' => '{value}',
],
],
],
],
'=' => [
'whereClause' => [
'id=s' => [
'from' => 'EntityPhoneNumber',
'select' => ['entityId'],
'joins' => [
[
'phoneNumber',
'phoneNumber',
[
'phoneNumber.id:' => 'phoneNumberId',
'phoneNumber.deleted' => false,
],
],
],
'whereClause' => [
Attribute::DELETED => false,
'entityType' => $entityType,
'phoneNumber.numeric' => '{value}',
],
],
],
],
'<>' => [
'whereClause' => [
'id!=s' => [
'from' => 'EntityPhoneNumber',
'select' => ['entityId'],
'joins' => [
[
'phoneNumber',
'phoneNumber',
[
'phoneNumber.id:' => 'phoneNumberId',
'phoneNumber.deleted' => false,
],
],
],
'whereClause' => [
Attribute::DELETED => false,
'entityType' => $entityType,
'phoneNumber.numeric' => '{value}',
],
],
],
],
'IN' => [
'whereClause' => [
'id=s' => [
'from' => 'EntityPhoneNumber',
'select' => ['entityId'],
'joins' => [
[
'phoneNumber',
'phoneNumber',
[
'phoneNumber.id:' => 'phoneNumberId',
'phoneNumber.deleted' => false,
],
],
],
'whereClause' => [
Attribute::DELETED => false,
'entityType' => $entityType,
'phoneNumber.numeric' => '{value}',
],
],
],
],
'NOT IN' => [
'whereClause' => [
'id!=s' => [
'from' => 'EntityPhoneNumber',
'select' => ['entityId'],
'joins' => [
[
'phoneNumber',
'phoneNumber',
[
'phoneNumber.id:' => 'phoneNumberId',
'phoneNumber.deleted' => false,
],
],
],
'whereClause' => [
Attribute::DELETED => false,
'entityType' => $entityType,
'phoneNumber.numeric' => '{value}',
],
],
],
],
'IS NULL' => [
'leftJoins' => [['phoneNumbers', 'phoneNumbersMultiple']],
'whereClause' => [
'phoneNumbersMultiple.numeric=' => null,
]
],
'IS NOT NULL' => [
'whereClause' => [
'id=s' => [
'from' => 'EntityPhoneNumber',
'select' => ['entityId'],
'whereClause' => [
Attribute::DELETED => false,
'entityType' => $entityType,
],
],
],
],
],
];
}
}

View File

@@ -0,0 +1,40 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils\Database\Orm;
use Espo\ORM\Defs\IndexDefs;
interface IndexHelper
{
/**
* Compose an index DB name. Depending on database, the name can be unique, limited by a max length.
*/
public function composeKey(IndexDefs $defs, string $entityType): string;
}

View File

@@ -0,0 +1,55 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils\Database\Orm;
use Espo\Core\InjectableFactory;
use Espo\Core\Utils\Metadata;
use RuntimeException;
class IndexHelperFactory
{
public function __construct(
private Metadata $metadata,
private InjectableFactory $injectableFactory
) {}
public function create(string $platform): IndexHelper
{
/** @var ?class-string<IndexHelper> $className */
$className = $this->metadata
->get(['app', 'databasePlatforms', $platform, 'indexHelperClassName']);
if (!$className) {
throw new RuntimeException("No Index Helper for {$platform}");
}
return $this->injectableFactory->create($className);
}
}

View File

@@ -0,0 +1,51 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils\Database\Orm\IndexHelpers;
use Espo\Core\Utils\Database\Orm\IndexHelper;
use Espo\Core\Utils\Util;
use Espo\ORM\Defs\IndexDefs;
class MysqlIndexHelper implements IndexHelper
{
private const MAX_LENGTH = 60;
public function composeKey(IndexDefs $defs, string $entityType): string
{
$name = $defs->getName();
$prefix = $defs->isUnique() ? 'UNIQ' : 'IDX';
$parts = [$prefix, strtoupper(Util::toUnderScore($name))];
$key = implode('_', $parts);
return substr($key, 0, self::MAX_LENGTH);
}
}

View File

@@ -0,0 +1,81 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils\Database\Orm\IndexHelpers;
use Espo\Core\Utils\Database\Orm\IndexHelper;
use Espo\Core\Utils\Util;
use Espo\ORM\Defs\IndexDefs;
class PostgresqlIndexHelper implements IndexHelper
{
private const MAX_LENGTH = 59;
public function composeKey(IndexDefs $defs, string $entityType): string
{
$name = $defs->getName();
$prefix = $defs->isUnique() ? 'UNIQ' : 'IDX';
$parts = [
$prefix,
strtoupper(Util::toUnderScore($entityType)),
strtoupper(Util::toUnderScore($name)),
];
$key = implode('_', $parts);
return self::decreaseLength($key);
}
private static function decreaseLength(string $key): string
{
if (strlen($key) <= self::MAX_LENGTH) {
return $key;
}
$list = explode('_', $key);
$maxItemLength = 0;
foreach ($list as $item) {
if (strlen($item) > $maxItemLength) {
$maxItemLength = strlen($item);
}
}
$maxItemLength--;
$list = array_map(
fn ($item) => substr($item, 0, min($maxItemLength, strlen($item))),
$list
);
$key = implode('_', $list);
return self::decreaseLength($key);
}
}

View File

@@ -0,0 +1,41 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils\Database\Orm;
use Espo\ORM\Defs\RelationDefs;
use Espo\Core\Utils\Database\Orm\Defs\EntityDefs;
/**
* Converts link definitions to ORM definitions.
*/
interface LinkConverter
{
public function convert(RelationDefs $linkDefs, string $entityType): EntityDefs;
}

View File

@@ -0,0 +1,70 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils\Database\Orm\LinkConverters;
use Espo\Core\Utils\Database\Orm\Defs\AttributeDefs;
use Espo\Core\Utils\Database\Orm\Defs\EntityDefs;
use Espo\Core\Utils\Database\Orm\LinkConverter;
use Espo\ORM\Defs\RelationDefs as LinkDefs;
use Espo\ORM\Type\AttributeType;
use LogicException;
class Attachments implements LinkConverter
{
public function __construct(private HasChildren $hasChildren) {}
public function convert(LinkDefs $linkDefs, string $entityType): EntityDefs
{
$name = $linkDefs->getName();
$entityDefs = $this->hasChildren->convert($linkDefs, $entityType);
$entityDefs = $entityDefs->withAttribute(
AttributeDefs::create($name . 'Types')
->withType(AttributeType::JSON_OBJECT)
->withNotStorable()
);
$relationDefs = $entityDefs->getRelation($name);
if (!$relationDefs) {
throw new LogicException();
}
$relationDefs = $relationDefs->withConditions([
'OR' => [
['field' => null],
['field' => $name],
]
]);
return $entityDefs->withRelation($relationDefs);
}
}

View File

@@ -0,0 +1,97 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils\Database\Orm\LinkConverters;
use Espo\Core\Utils\Database\Orm\Defs\AttributeDefs;
use Espo\Core\Utils\Database\Orm\Defs\EntityDefs;
use Espo\Core\Utils\Database\Orm\Defs\RelationDefs;
use Espo\Core\Utils\Database\Orm\LinkConverter;
use Espo\ORM\Defs\Params\AttributeParam;
use Espo\ORM\Defs\Params\RelationParam;
use Espo\ORM\Defs\RelationDefs as LinkDefs;
use Espo\ORM\Type\AttributeType;
use Espo\ORM\Type\RelationType;
class BelongsTo implements LinkConverter
{
public function convert(LinkDefs $linkDefs, string $entityType): EntityDefs
{
$name = $linkDefs->getName();
$foreignEntityType = $linkDefs->getForeignEntityType();
$foreignRelationName = $linkDefs->hasForeignRelationName() ? $linkDefs->getForeignRelationName() : null;
$noIndex = $linkDefs->getParam('noIndex');
$noForeignName = $linkDefs->getParam('noForeignName');
$foreignName = $linkDefs->getParam('foreignName') ?? 'name';
$noJoin = $linkDefs->getParam(RelationParam::NO_JOIN);
$idName = $name . 'Id';
$nameName = $name . 'Name';
$idAttributeDefs = AttributeDefs::create($idName)
->withType(AttributeType::FOREIGN_ID)
->withParam('index', !$noIndex);
$relationDefs = RelationDefs::create($name)
->withType(RelationType::BELONGS_TO)
->withForeignEntityType($foreignEntityType)
->withKey($idName)
->withForeignKey('id')
->withForeignRelationName($foreignRelationName);
if ($linkDefs->getParam(RelationParam::DEFERRED_LOAD)) {
$relationDefs = $relationDefs->withParam(RelationParam::DEFERRED_LOAD, true);
}
$nameAttributeDefs = !$noForeignName ?
(
$noJoin ?
AttributeDefs::create($nameName)
->withType(AttributeType::VARCHAR)
->withNotStorable()
->withParam(AttributeParam::RELATION, $name)
->withParam(AttributeParam::FOREIGN, $foreignName) :
AttributeDefs::create($nameName)
->withType(AttributeType::FOREIGN)
->withNotStorable() // Used to be false before v7.4.
->withParam(AttributeParam::RELATION, $name)
->withParam(AttributeParam::FOREIGN, $foreignName)
) : null;
$entityDefs = EntityDefs::create()
->withAttribute($idAttributeDefs)
->withRelation($relationDefs);
if ($nameAttributeDefs) {
$entityDefs = $entityDefs->withAttribute($nameAttributeDefs);
}
return $entityDefs;
}
}

View File

@@ -0,0 +1,86 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils\Database\Orm\LinkConverters;
use Espo\Core\Utils\Database\Orm\Defs\AttributeDefs;
use Espo\Core\Utils\Database\Orm\Defs\EntityDefs;
use Espo\Core\Utils\Database\Orm\Defs\RelationDefs;
use Espo\Core\Utils\Database\Orm\LinkConverter;
use Espo\ORM\Defs\Params\AttributeParam;
use Espo\ORM\Defs\Params\RelationParam;
use Espo\ORM\Defs\RelationDefs as LinkDefs;
use Espo\ORM\Type\AttributeType;
use Espo\ORM\Type\RelationType;
class BelongsToParent implements LinkConverter
{
private const TYPE_LENGTH = 100;
public function convert(LinkDefs $linkDefs, string $entityType): EntityDefs
{
$name = $linkDefs->getName();
$foreignRelationName = $linkDefs->hasForeignRelationName() ?
$linkDefs->getForeignRelationName() : null;
$idName = $name . 'Id';
$nameName = $name . 'Name';
$typeName = $name . 'Type';
$relationDefs = RelationDefs::create($name)
->withType(RelationType::BELONGS_TO_PARENT)
->withKey($idName)
->withForeignRelationName($foreignRelationName);
if ($linkDefs->getParam(RelationParam::DEFERRED_LOAD)) {
$relationDefs = $relationDefs->withParam(RelationParam::DEFERRED_LOAD, true);
}
return EntityDefs::create()
->withAttribute(
AttributeDefs::create($idName)
->withType(AttributeType::FOREIGN_ID)
->withParam('index', $name)
)
->withAttribute(
AttributeDefs::create($typeName)
->withType(AttributeType::FOREIGN_TYPE)
->withParam(AttributeParam::NOT_NULL, false) // Revise whether needed.
->withParam('index', $name)
->withLength(self::TYPE_LENGTH)
)
->withAttribute(
AttributeDefs::create($nameName)
->withType(AttributeType::VARCHAR)
->withNotStorable()
)
->withRelation($relationDefs);
}
}

View File

@@ -0,0 +1,78 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils\Database\Orm\LinkConverters;
use Espo\Core\Utils\Database\Orm\Defs\AttributeDefs;
use Espo\Core\Utils\Database\Orm\Defs\EntityDefs;
use Espo\Core\Utils\Database\Orm\Defs\RelationDefs;
use Espo\Core\Utils\Database\Orm\LinkConverter;
use Espo\Entities\EmailAddress;
use Espo\ORM\Defs\RelationDefs as LinkDefs;
use Espo\ORM\Name\Attribute;
use Espo\ORM\Type\AttributeType;
use Espo\ORM\Type\RelationType;
class EmailEmailAddress implements LinkConverter
{
public function __construct() {}
public function convert(LinkDefs $linkDefs, string $entityType): EntityDefs
{
$name = $linkDefs->getName();
$hasField = $linkDefs->getParam('hasField');
$foreignEntityType = EmailAddress::ENTITY_TYPE;
$key1 = lcfirst($entityType) . 'Id';
$key2 = lcfirst($foreignEntityType) . 'Id';
$relationDefs = RelationDefs::create($name)
->withType(RelationType::MANY_MANY)
->withForeignEntityType($foreignEntityType)
->withKey(Attribute::ID)
->withForeignKey(Attribute::ID)
->withMidKeys($key1, $key2);
return EntityDefs::create()
->withAttribute(
AttributeDefs::create($name . 'Ids')
->withType(AttributeType::JSON_ARRAY)
->withNotStorable()
->withParam('isLinkStub', !$hasField)
)
->withAttribute(
AttributeDefs::create($name . 'Names')
->withType(AttributeType::JSON_OBJECT)
->withNotStorable()
->withParam('isLinkStub', !$hasField)
)
->withRelation($relationDefs);
}
}

View File

@@ -0,0 +1,68 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils\Database\Orm\LinkConverters;
use Espo\Core\Utils\Database\Orm\Defs\AttributeDefs;
use Espo\Core\Utils\Database\Orm\Defs\EntityDefs;
use Espo\Core\Utils\Database\Orm\Defs\RelationDefs;
use Espo\Core\Utils\Database\Orm\LinkConverter;
use Espo\Entities\User;
use Espo\ORM\Defs\RelationDefs as LinkDefs;
use Espo\ORM\Type\AttributeType;
use Espo\ORM\Type\RelationType;
/**
* @noinspection PhpUnused
*/
class EntityCollaborator implements LinkConverter
{
private const ENTITY_TYPE_LENGTH = 100;
public function convert(LinkDefs $linkDefs, string $entityType): EntityDefs
{
$name = $linkDefs->getName();
$relationshipName = $linkDefs->getRelationshipName();
return EntityDefs::create()
->withRelation(
RelationDefs::create($name)
->withType(RelationType::MANY_MANY)
->withForeignEntityType(User::ENTITY_TYPE)
->withRelationshipName($relationshipName)
->withMidKeys('entityId', 'userId')
->withConditions(['entityType' => $entityType])
->withAdditionalColumn(
AttributeDefs::create('entityType')
->withType(AttributeType::VARCHAR)
->withLength(self::ENTITY_TYPE_LENGTH)
)
);
}
}

View File

@@ -0,0 +1,65 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils\Database\Orm\LinkConverters;
use Espo\Core\Utils\Database\Orm\Defs\AttributeDefs;
use Espo\Core\Utils\Database\Orm\Defs\EntityDefs;
use Espo\Core\Utils\Database\Orm\Defs\RelationDefs;
use Espo\Core\Utils\Database\Orm\LinkConverter;
use Espo\Entities\Team;
use Espo\ORM\Defs\RelationDefs as LinkDefs;
use Espo\ORM\Type\AttributeType;
use Espo\ORM\Type\RelationType;
class EntityTeam implements LinkConverter
{
private const ENTITY_TYPE_LENGTH = 100;
public function convert(LinkDefs $linkDefs, string $entityType): EntityDefs
{
$name = $linkDefs->getName();
$relationshipName = $linkDefs->getRelationshipName();
return EntityDefs::create()
->withRelation(
RelationDefs::create($name)
->withType(RelationType::MANY_MANY)
->withForeignEntityType(Team::ENTITY_TYPE)
->withRelationshipName($relationshipName)
->withMidKeys('entityId', 'teamId')
->withConditions(['entityType' => $entityType])
->withAdditionalColumn(
AttributeDefs::create('entityType')
->withType(AttributeType::VARCHAR)
->withLength(self::ENTITY_TYPE_LENGTH)
)
);
}
}

View File

@@ -0,0 +1,65 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils\Database\Orm\LinkConverters;
use Espo\Core\Utils\Database\Orm\Defs\AttributeDefs;
use Espo\Core\Utils\Database\Orm\Defs\EntityDefs;
use Espo\Core\Utils\Database\Orm\Defs\RelationDefs;
use Espo\Core\Utils\Database\Orm\LinkConverter;
use Espo\Entities\User;
use Espo\ORM\Defs\RelationDefs as LinkDefs;
use Espo\ORM\Type\AttributeType;
use Espo\ORM\Type\RelationType;
class EntityUser implements LinkConverter
{
private const ENTITY_TYPE_LENGTH = 100;
public function convert(LinkDefs $linkDefs, string $entityType): EntityDefs
{
$name = $linkDefs->getName();
$relationshipName = $linkDefs->getRelationshipName();
return EntityDefs::create()
->withRelation(
RelationDefs::create($name)
->withType(RelationType::MANY_MANY)
->withForeignEntityType(User::ENTITY_TYPE)
->withRelationshipName($relationshipName)
->withMidKeys('entityId', 'userId')
->withConditions(['entityType' => $entityType])
->withAdditionalColumn(
AttributeDefs::create('entityType')
->withType(AttributeType::VARCHAR)
->withLength(self::ENTITY_TYPE_LENGTH)
)
);
}
}

View File

@@ -0,0 +1,71 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils\Database\Orm\LinkConverters;
use Espo\Core\Utils\Database\Orm\Defs\AttributeDefs;
use Espo\Core\Utils\Database\Orm\Defs\EntityDefs;
use Espo\Core\Utils\Database\Orm\Defs\RelationDefs;
use Espo\Core\Utils\Database\Orm\LinkConverter;
use Espo\ORM\Defs\RelationDefs as LinkDefs;
use Espo\ORM\Type\AttributeType;
use Espo\ORM\Type\RelationType;
class HasChildren implements LinkConverter
{
public function convert(LinkDefs $linkDefs, string $entityType): EntityDefs
{
$name = $linkDefs->getName();
$foreignEntityType = $linkDefs->getForeignEntityType();
$foreignRelationName = $linkDefs->hasForeignRelationName() ? $linkDefs->getForeignRelationName() : null;
$hasField = $linkDefs->getParam('hasField');
$relationDefs = RelationDefs::create($name)
->withType(RelationType::HAS_CHILDREN)
->withForeignEntityType($foreignEntityType)
->withForeignKey($foreignRelationName . 'Id')
->withParam('foreignType', $foreignRelationName . 'Type')
->withForeignRelationName($foreignRelationName);
return EntityDefs::create()
->withAttribute(
AttributeDefs::create($name . 'Ids')
->withType(AttributeType::JSON_ARRAY)
->withNotStorable()
->withParam('isLinkStub', !$hasField) // Revise. Change to notExportable?
)
->withAttribute(
AttributeDefs::create($name . 'Names')
->withType(AttributeType::JSON_OBJECT)
->withNotStorable()
->withParam('isLinkStub', !$hasField)
)
->withRelation($relationDefs);
}
}

View File

@@ -0,0 +1,88 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils\Database\Orm\LinkConverters;
use Espo\Core\Utils\Database\Orm\Defs\AttributeDefs;
use Espo\Core\Utils\Database\Orm\Defs\EntityDefs;
use Espo\Core\Utils\Database\Orm\Defs\RelationDefs;
use Espo\Core\Utils\Database\Orm\LinkConverter;
use Espo\Core\Utils\Log;
use Espo\ORM\Defs\RelationDefs as LinkDefs;
use Espo\ORM\Type\AttributeType;
use Espo\ORM\Type\RelationType;
class HasMany implements LinkConverter
{
public function __construct(private Log $log) {}
public function convert(LinkDefs $linkDefs, string $entityType): EntityDefs
{
$name = $linkDefs->getName();
$foreignEntityType = $linkDefs->getForeignEntityType();
$foreignRelationName = $linkDefs->getForeignRelationName();
$hasField = $linkDefs->getParam('hasField');
$type = RelationType::HAS_MANY;
/*$type = $linkDefs->hasRelationshipName() ?
RelationType::MANY_MANY : // Revise.
RelationType::HAS_MANY;*/
if ($linkDefs->hasRelationshipName()) {
$this->log->warning(
"Issue with the link '{$name}' in '{$entityType}' entity type. Might be the foreign link " .
"'{$foreignRelationName}' in '{$foreignEntityType}' entity type is missing. " .
"Remove the problem link manually.");
return EntityDefs::create();
}
return EntityDefs::create()
->withAttribute(
AttributeDefs::create($name . 'Ids')
->withType(AttributeType::JSON_ARRAY)
->withNotStorable()
->withParam('isLinkStub', !$hasField) // Revise. Change to notExportable?
)
->withAttribute(
AttributeDefs::create($name . 'Names')
->withType(AttributeType::JSON_OBJECT)
->withNotStorable()
->withParam('isLinkStub', !$hasField)
)
->withRelation(
RelationDefs::create($name)
->withType($type)
->withForeignEntityType($foreignEntityType)
->withForeignKey($foreignRelationName . 'Id')
->withForeignRelationName($foreignRelationName)
);
}
}

View File

@@ -0,0 +1,91 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils\Database\Orm\LinkConverters;
use Espo\Core\Utils\Database\Orm\Defs\AttributeDefs;
use Espo\Core\Utils\Database\Orm\Defs\EntityDefs;
use Espo\Core\Utils\Database\Orm\Defs\RelationDefs;
use Espo\Core\Utils\Database\Orm\LinkConverter;
use Espo\ORM\Defs\Params\AttributeParam;
use Espo\ORM\Defs\RelationDefs as LinkDefs;
use Espo\ORM\Name\Attribute;
use Espo\ORM\Type\AttributeType;
use Espo\ORM\Type\RelationType;
class HasOne implements LinkConverter
{
public function convert(LinkDefs $linkDefs, string $entityType): EntityDefs
{
$name = $linkDefs->getName();
$foreignEntityType = $linkDefs->getForeignEntityType();
$foreignRelationName = $linkDefs->hasForeignRelationName() ? $linkDefs->getForeignRelationName() : null;
$noForeignName = $linkDefs->getParam('noForeignName');
$foreignName = $linkDefs->getParam('foreignName') ?? 'name';
$noJoin = $linkDefs->getParam('noJoin');
$idName = $name . 'Id';
$nameName = $name . 'Name';
$idAttributeDefs = AttributeDefs::create($idName)
->withType($noJoin ? AttributeType::VARCHAR : AttributeType::FOREIGN)
->withNotStorable()
->withParam(AttributeParam::RELATION, $name)
->withParam(AttributeParam::FOREIGN, Attribute::ID);
$nameAttributeDefs = !$noForeignName ?
(
AttributeDefs::create($nameName)
->withType($noJoin ? AttributeType::VARCHAR : AttributeType::FOREIGN)
->withNotStorable()
->withParam(AttributeParam::RELATION, $name)
->withParam(AttributeParam::FOREIGN, $foreignName)
) : null;
$relationDefs = RelationDefs::create($name)
->withType(RelationType::HAS_ONE)
->withForeignEntityType($foreignEntityType);
if ($foreignRelationName) {
$relationDefs = $relationDefs
->withForeignKey($foreignRelationName . 'Id')
->withForeignRelationName($foreignRelationName);
}
$entityDefs = EntityDefs::create()
->withAttribute($idAttributeDefs)
->withRelation($relationDefs);
if ($nameAttributeDefs) {
$entityDefs = $entityDefs->withAttribute($nameAttributeDefs);
}
return $entityDefs;
}
}

View File

@@ -0,0 +1,110 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils\Database\Orm\LinkConverters;
use Espo\Core\Utils\Database\Orm\Defs\AttributeDefs;
use Espo\Core\Utils\Database\Orm\Defs\EntityDefs;
use Espo\Core\Utils\Database\Orm\Defs\RelationDefs;
use Espo\Core\Utils\Database\Orm\LinkConverter;
use Espo\Core\Utils\Util;
use Espo\ORM\Defs\RelationDefs as LinkDefs;
use Espo\ORM\Name\Attribute;
use Espo\ORM\Type\AttributeType;
use Espo\ORM\Type\RelationType;
class ManyMany implements LinkConverter
{
public function convert(LinkDefs $linkDefs, string $entityType): EntityDefs
{
$name = $linkDefs->getName();
$foreignEntityType = $linkDefs->getForeignEntityType();
$foreignRelationName = $linkDefs->getForeignRelationName();
$hasField = $linkDefs->getParam('hasField');
$columnAttributeMap = $linkDefs->getParam('columnAttributeMap');
$relationshipName = $linkDefs->hasRelationshipName() ?
$linkDefs->getRelationshipName() :
self::composeRelationshipName($entityType, $foreignEntityType);
if ($linkDefs->hasMidKey() && $linkDefs->hasForeignMidKey()) {
$key1 = $linkDefs->getMidKey();
$key2 = $linkDefs->getForeignMidKey();
} else {
$key1 = lcfirst($entityType) . 'Id';
$key2 = lcfirst($foreignEntityType) . 'Id';
if ($key1 === $key2) {
[$key1, $key2] = strcmp($name, $foreignRelationName) > 0 ?
['leftId', 'rightId'] :
['rightId', 'leftId'];
}
}
$relationDefs = RelationDefs::create($name)
->withType(RelationType::MANY_MANY)
->withForeignEntityType($foreignEntityType)
->withRelationshipName($relationshipName)
->withKey(Attribute::ID)
->withForeignKey(Attribute::ID)
->withMidKeys($key1, $key2)
->withForeignRelationName($foreignRelationName);
if ($columnAttributeMap) {
$relationDefs = $relationDefs->withParam('columnAttributeMap', $columnAttributeMap);
}
return EntityDefs::create()
->withAttribute(
AttributeDefs::create($name . 'Ids')
->withType(AttributeType::JSON_ARRAY)
->withNotStorable()
->withParam('isLinkStub', !$hasField) // Revise.
)
->withAttribute(
AttributeDefs::create($name . 'Names')
->withType(AttributeType::JSON_OBJECT)
->withNotStorable()
->withParam('isLinkStub', !$hasField) // Revise.
)
->withRelation($relationDefs);
}
private static function composeRelationshipName(string $left, string $right): string
{
$parts = [
Util::toCamelCase(lcfirst($left)),
Util::toCamelCase(lcfirst($right)),
];
sort($parts);
return Util::toCamelCase(implode('_', $parts));
}
}

View File

@@ -0,0 +1,73 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils\Database\Orm\LinkConverters;
use Espo\Core\Utils\Database\Orm\Defs\AttributeDefs;
use Espo\Core\Utils\Database\Orm\Defs\EntityDefs;
use Espo\Core\Utils\Database\Orm\Defs\RelationDefs;
use Espo\Core\Utils\Database\Orm\LinkConverter;
use Espo\Entities\PhoneNumber;
use Espo\ORM\Defs\RelationDefs as LinkDefs;
use Espo\ORM\Type\AttributeType;
use Espo\ORM\Type\RelationType;
class SmsPhoneNumber implements LinkConverter
{
public function __construct() {}
public function convert(LinkDefs $linkDefs, string $entityType): EntityDefs
{
$name = $linkDefs->getName();
$foreignEntityType = PhoneNumber::ENTITY_TYPE;
$key1 = lcfirst($entityType) . 'Id';
$key2 = lcfirst($foreignEntityType) . 'Id';
$relationDefs = RelationDefs::create($name)
->withType(RelationType::MANY_MANY)
->withForeignEntityType($foreignEntityType)
->withKey('id')
->withForeignKey('id')
->withMidKeys($key1, $key2);
return EntityDefs::create()
->withAttribute(
AttributeDefs::create($name . 'Ids')
->withType(AttributeType::JSON_ARRAY)
->withNotStorable()
)
->withAttribute(
AttributeDefs::create($name . 'Names')
->withType(AttributeType::JSON_OBJECT)
->withNotStorable()
)
->withRelation($relationDefs);
}
}

View File

@@ -0,0 +1,272 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils\Database\Orm;
use Espo\Core\InjectableFactory;
use Espo\Core\Utils\Database\Orm\LinkConverters\BelongsTo;
use Espo\Core\Utils\Database\Orm\LinkConverters\BelongsToParent;
use Espo\Core\Utils\Database\Orm\LinkConverters\HasChildren;
use Espo\Core\Utils\Database\Orm\LinkConverters\HasMany;
use Espo\Core\Utils\Database\Orm\LinkConverters\HasOne;
use Espo\Core\Utils\Database\Orm\LinkConverters\ManyMany;
use Espo\Core\Utils\Log;
use Espo\Core\Utils\Util;
use Espo\Core\Utils\Metadata;
use Espo\ORM\Defs\Params\AttributeParam;
use Espo\ORM\Defs\Params\EntityParam;
use Espo\ORM\Defs\Params\RelationParam;
use Espo\ORM\Defs\RelationDefs;
use Espo\ORM\Type\AttributeType;
use Espo\ORM\Type\RelationType;
use RuntimeException;
class RelationConverter
{
private const DEFAULT_VARCHAR_LENGTH = 255;
/** @var string[] */
private $mergeParams = [
RelationParam::RELATION_NAME,
RelationParam::CONDITIONS,
RelationParam::ADDITIONAL_COLUMNS,
'noJoin',
RelationParam::INDEXES,
];
/** @var string[] */
private $manyMergeParams = [
RelationParam::ORDER_BY,
RelationParam::ORDER,
];
public function __construct(
private Metadata $metadata,
private InjectableFactory $injectableFactory,
private Log $log
) {}
/**
* @param string $name
* @param array<string, mixed> $params
* @param string $entityType
* @return ?array<string, mixed>
*/
public function process(string $name, array $params, string $entityType): ?array
{
$foreignEntityType = $params[RelationParam::ENTITY] ?? null;
$foreignLinkName = $params[RelationParam::FOREIGN] ?? null;
/** @var ?array<string, mixed> $foreignParams */
$foreignParams = $foreignEntityType && $foreignLinkName ?
$this->metadata->get(['entityDefs', $foreignEntityType, 'links', $foreignLinkName]) :
null;
/** @var ?string $relationshipName */
$relationshipName = $params[RelationParam::RELATION_NAME] ?? null;
if ($relationshipName) {
$relationshipName = lcfirst($relationshipName);
$params[RelationParam::RELATION_NAME] = $relationshipName;
}
$linkType = $params[RelationParam::TYPE] ?? null;
$foreignLinkType = $foreignParams ? $foreignParams[RelationParam::TYPE] : null;
if (!$linkType) {
$this->log->warning("Link $entityType.$name has no type.");
return null;
}
$params['hasField'] = (bool) $this->metadata
->get(['entityDefs', $entityType, 'fields', $name]);
$relationDefs = RelationDefs::fromRaw($params, $name);
$converter = $this->createLinkConverter($relationshipName, $linkType, $foreignLinkType);
$convertedEntityDefs = $converter->convert($relationDefs, $entityType);
$raw = $convertedEntityDefs->toAssoc();
if (isset($raw[EntityParam::RELATIONS][$name])) {
$this->mergeParams($raw[EntityParam::RELATIONS][$name], $params, $foreignParams ?? [], $linkType);
$this->correct($raw[EntityParam::RELATIONS][$name]);
}
return [$entityType => $raw];
}
private function createLinkConverter(?string $relationship, string $type, ?string $foreignType): LinkConverter
{
$className = $this->getLinkConverterClassName($relationship, $type, $foreignType);
return $this->injectableFactory->create($className);
}
/**
* @return class-string<LinkConverter>
*/
private function getLinkConverterClassName(?string $relationship, string $type, ?string $foreignType): string
{
if ($relationship) {
/** @var class-string<LinkConverter> $className */
$className = $this->metadata->get(['app', 'relationships', $relationship, 'converterClassName']);
if ($className) {
return $className;
}
}
if ($type === RelationType::HAS_MANY && $foreignType === RelationType::HAS_MANY) {
return ManyMany::class;
}
if ($type === RelationType::HAS_MANY) {
return HasMany::class;
}
if ($type === RelationType::HAS_CHILDREN) {
return HasChildren::class;
}
if ($type === RelationType::HAS_ONE) {
return HasOne::class;
}
if ($type === RelationType::BELONGS_TO) {
return BelongsTo::class;
}
if ($type === RelationType::BELONGS_TO_PARENT) {
return BelongsToParent::class;
}
throw new RuntimeException("Unsupported link type '$type'.");
}
/**
* @param array<string, mixed> $relationDefs
* @param array<string, mixed> $params
* @param array<string, mixed> $foreignParams
*/
private function mergeParams(array &$relationDefs, array $params, array $foreignParams, string $linkType): void
{
$mergeParams = $this->mergeParams;
if (
$linkType === RelationType::HAS_MANY ||
$linkType === RelationType::HAS_CHILDREN
) {
$mergeParams = array_merge($mergeParams, $this->manyMergeParams);
}
foreach ($mergeParams as $name) {
$additionalParam = $this->getMergedParam($name, $params, $foreignParams);
if ($additionalParam === null) {
continue;
}
$relationDefs[$name] = $additionalParam;
}
}
/**
* @param array<string, mixed> $params
* @param array<string, mixed> $foreignParams
* @return array<string, mixed>|scalar|null
*/
private function getMergedParam(string $name, array $params, array $foreignParams): mixed
{
$value = $params[$name] ?? null;
$foreignValue = $foreignParams[$name] ?? null;
if ($value !== null && $foreignValue !== null) {
if (!empty($value) && !is_array($value)) {
return $value;
}
if (!empty($foreignValue) && !is_array($foreignValue)) {
return $foreignValue;
}
/** @var array<int|string, mixed> $value */
/** @var array<int|string, mixed> $foreignValue */
/** @var array<string, mixed> */
return Util::merge($value, $foreignValue);
}
if (isset($value)) {
return $value;
}
if (isset($foreignValue)) {
return $foreignValue;
}
return null;
}
/**
* @param array<string, mixed> $relationDefs
*/
private function correct(array &$relationDefs): void
{
if (
isset($relationDefs[RelationParam::ORDER]) &&
is_string($relationDefs[RelationParam::ORDER])
) {
$relationDefs[RelationParam::ORDER] = strtoupper($relationDefs[RelationParam::ORDER]);
}
if (!isset($relationDefs[RelationParam::ADDITIONAL_COLUMNS])) {
return;
}
/** @var array<string, array<string, mixed>> $additionalColumns */
$additionalColumns = &$relationDefs[RelationParam::ADDITIONAL_COLUMNS];
foreach ($additionalColumns as &$columnDefs) {
$columnDefs[AttributeParam::TYPE] ??= AttributeType::VARCHAR;
if (
$columnDefs[AttributeParam::TYPE] === AttributeType::VARCHAR &&
!isset($columnDefs[AttributeParam::LEN])
) {
$columnDefs[AttributeParam::LEN] = self::DEFAULT_VARCHAR_LENGTH;
}
}
$relationDefs[RelationParam::ADDITIONAL_COLUMNS] = $additionalColumns;
}
}

View File

@@ -0,0 +1,488 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils\Database\Schema;
use Espo\Core\InjectableFactory;
use Espo\Core\Utils\Database\ConfigDataProvider;
use Espo\Core\Utils\Database\MetadataProvider as MetadataProvider;
use Espo\Core\Utils\Log;
use Espo\Core\Utils\Util;
use Espo\ORM\Defs\AttributeDefs;
use Espo\ORM\Defs\EntityDefs;
use Espo\ORM\Defs\IndexDefs;
use Espo\ORM\Defs\Params\AttributeParam;
use Espo\ORM\Defs\Params\EntityParam;
use Espo\ORM\Defs\Params\RelationParam;
use Espo\ORM\Defs\RelationDefs;
use Espo\ORM\Entity;
use Doctrine\DBAL\Types\Types;
use Doctrine\DBAL\Schema\SchemaException;
use Doctrine\DBAL\Schema\Table;
use Doctrine\DBAL\Schema\Schema as DbalSchema;
use Doctrine\DBAL\Types\Type as DbalType;
use Espo\ORM\Type\AttributeType;
/**
* Schema representation builder.
*/
class Builder
{
private const ATTR_ID = 'id';
private const ATTR_DELETED = 'deleted';
private int $idLength;
private string $idDbType;
/** @var string[] */
private $typeList;
private ColumnPreparator $columnPreparator;
public function __construct(
private Log $log,
private InjectableFactory $injectableFactory,
ConfigDataProvider $configDataProvider,
ColumnPreparatorFactory $columnPreparatorFactory,
MetadataProvider $metadataProvider
) {
$this->typeList = array_keys(DbalType::getTypesMap());
$platform = $configDataProvider->getPlatform();
$this->columnPreparator = $columnPreparatorFactory->create($platform);
$this->idLength = $metadataProvider->getIdLength();
$this->idDbType = $metadataProvider->getIdDbType();
}
/**
* Build a schema representation for an ORM metadata.
*
* @param array<string, mixed> $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[EntityParam::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<string, Table> $tables
* @throws SchemaException
*/
private function buildEntity(EntityDefs $entityDefs, DbalSchema $schema, array &$tables): void
{
if ($entityDefs->getParam('skipRebuild')) {
return;
}
$entityType = $entityDefs->getName();
$modifier = $this->getEntityDefsModifier($entityDefs);
if ($modifier) {
$modifiedEntityDefs = $modifier->modify($entityDefs);
$entityDefs = EntityDefs::fromRaw($modifiedEntityDefs->toAssoc(), $entityType);
}
$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<string, mixed> $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->warning(
'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());
}
private function getEntityDefsModifier(EntityDefs $entityDefs): ?EntityDefsModifier
{
/** @var ?class-string<EntityDefsModifier> $modifierClassName */
$modifierClassName = $entityDefs->getParam('modifierClassName');
if (!$modifierClassName) {
return null;
}
return $this->injectableFactory->create($modifierClassName);
}
/**
* @param array<string, mixed> $ormMeta
* @param ?string[] $entityTypeList
* @return array<string, mixed>
*/
private function amendMetadata(array $ormMeta, ?array $entityTypeList): array
{
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<string, mixed> $ormMeta */
$ormMeta = Util::unsetInArray($ormMeta, $ormMeta['unset']);
unset($ormMeta['unset']);
}
if (isset($protectedOrmMeta)) {
/** @var array<string, mixed> $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<string, Table> $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([
AttributeParam::DB_TYPE => Types::BIGINT,
'type' => Entity::ID,
AttributeParam::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([
'type' => Entity::FOREIGN_ID,
AttributeParam::DB_TYPE => $this->idDbType,
AttributeParam::LEN => $this->idLength,
], $midKey)
);
$this->addColumn($table, $column);
}
/** @var array<string, array<string, mixed>> $additionalColumns */
$additionalColumns = $relationDefs->getParam(RelationParam::ADDITIONAL_COLUMNS) ?? [];
foreach ($additionalColumns as $fieldName => $fieldParams) {
if ($fieldParams['type'] === AttributeType::FOREIGN_ID) {
$fieldParams = array_merge([
AttributeParam::DB_TYPE => $this->idDbType,
AttributeParam::LEN => $this->idLength,
], $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<string, mixed>
*/
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();
}
if ($column->getFixed() !== null) {
$result['fixed'] = $column->getFixed();
}
// Can't use customSchemaOptions as it causes unwanted ALTER TABLE.
$result['platformOptions'] = [];
if ($column->getCollation()) {
$result['platformOptions']['collation'] = $column->getCollation();
}
if ($column->getCharset()) {
$result['platformOptions']['charset'] = $column->getCharset();
}
return $result;
}
/**
* @param string[] $entityTypeList
* @param array<string, mixed> $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;
}
}

View File

@@ -0,0 +1,203 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils\Database\Schema;
/**
* A DB column parameters.
*/
class Column
{
private bool $notNull = false;
private ?int $length = null;
private mixed $default = null;
private ?bool $autoincrement = null;
private ?int $precision = null;
private ?int $scale = null;
private ?bool $unsigned = null;
private ?bool $fixed = null;
private ?string $collation = null;
private ?string $charset = null;
private function __construct(
private string $name,
private string $type
) {}
public static function create(string $name, string $type): self
{
return new self($name, $type);
}
public function getName(): string
{
return $this->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 getFixed(): ?bool
{
return $this->fixed;
}
public function getCollation(): ?string
{
return $this->collation;
}
public function getCharset(): ?string
{
return $this->charset;
}
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;
}
/**
* Unsigned. 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;
}
/**
* Fixed length. For string and binary types.
*/
public function withFixed(?bool $fixed = true): self
{
$obj = clone $this;
$obj->fixed = $fixed;
return $obj;
}
public function withCollation(?string $collation): self
{
$obj = clone $this;
$obj->collation = $collation;
return $obj;
}
public function withCharset(?string $charset): self
{
$obj = clone $this;
$obj->charset = $charset;
return $obj;
}
}

View File

@@ -0,0 +1,37 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils\Database\Schema;
use Espo\ORM\Defs\AttributeDefs;
interface ColumnPreparator
{
public function prepare(AttributeDefs $defs): Column;
}

View File

@@ -0,0 +1,62 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils\Database\Schema;
use Espo\Core\Binding\BindingContainerBuilder;
use Espo\Core\InjectableFactory;
use Espo\Core\Utils\Database\Helper;
use Espo\Core\Utils\Metadata;
use RuntimeException;
class ColumnPreparatorFactory
{
public function __construct(
private Metadata $metadata,
private InjectableFactory $injectableFactory,
private Helper $helper
) {}
public function create(string $platform): ColumnPreparator
{
/** @var ?class-string<ColumnPreparator> $className */
$className = $this->metadata
->get(['app', 'databasePlatforms', $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);
}
}

View File

@@ -0,0 +1,247 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils\Database\Schema\ColumnPreparators;
use Doctrine\DBAL\Types\Types;
use Espo\Core\Utils\Database\Dbal\Types\LongtextType;
use Espo\Core\Utils\Database\Dbal\Types\MediumtextType;
use Espo\Core\Utils\Database\Helper;
use Espo\Core\Utils\Database\Schema\Column;
use Espo\Core\Utils\Database\Schema\ColumnPreparator;
use Espo\Core\Utils\Util;
use Espo\ORM\Defs\AttributeDefs;
use Espo\ORM\Defs\Params\AttributeParam;
use Espo\ORM\Entity;
class MysqlColumnPreparator implements ColumnPreparator
{
private const PARAM_DB_TYPE = AttributeParam::DB_TYPE;
private const PARAM_DEFAULT = AttributeParam::DEFAULT;
private const PARAM_NOT_NULL = AttributeParam::NOT_NULL;
private const PARAM_AUTOINCREMENT = 'autoincrement';
private const PARAM_PRECISION = 'precision';
private const PARAM_SCALE = 'scale';
private const PARAM_BINARY = 'binary';
public const TYPE_MYSQL = 'MySQL';
public const TYPE_MARIADB = 'MariaDB';
private const MB4_INDEX_LENGTH_LIMIT = 3072;
private const DEFAULT_INDEX_LIMIT = 1000;
/** @var string[] */
private array $mediumTextTypeList = [
Entity::TEXT,
Entity::JSON_OBJECT,
Entity::JSON_ARRAY,
];
/** @var array<string, string> */
private array $columnTypeMap = [
Entity::BOOL => Types::BOOLEAN,
Entity::INT => Types::INTEGER,
Entity::VARCHAR => Types::STRING,
];
private ?int $maxIndexLength = null;
public function __construct(
private Helper $helper
) {}
public function prepare(AttributeDefs $defs): Column
{
$dbType = $defs->getParam(self::PARAM_DB_TYPE);
$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);
$columnType = $dbType ?? $type;
if (in_array($type, $this->mediumTextTypeList) && !$dbType) {
$columnType = MediumtextType::NAME;
}
$columnType = $this->columnTypeMap[$columnType] ?? $columnType;
$columnName = Util::toUnderScore($defs->getName());
$column = Column::create($columnName, strtolower($columnType));
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:
$column = $column->withDefault(null);
break;
case Entity::JSON_ARRAY:
$default = is_array($default) ? json_encode($default) : null;
$column = $column->withDefault($default);
break;
case Entity::BOOL:
$default = intval($default ?? false);
$column = $column->withDefault($default);
break;
}
if ($type !== Entity::ID && $autoincrement) {
$column = $column
->withNotNull()
->withUnsigned();
}
if (
!in_array($columnType, [
Types::STRING,
Types::TEXT,
MediumtextType::NAME,
LongtextType::NAME,
])
) {
return $column;
}
$collation = $binary ?
'utf8mb4_bin' :
'utf8mb4_unicode_ci';
$charset = 'utf8mb4';
if ($mb3) {
$collation = $binary ?
'utf8mb3_bin' :
'utf8mb3_unicode_ci';
$charset = 'utf8mb3';
}
return $column
->withCollation($collation)
->withCharset($charset);
}
private function getMaxIndexLength(): int
{
if (!isset($this->maxIndexLength)) {
$this->maxIndexLength = $this->detectMaxIndexLength();
}
return $this->maxIndexLength;
}
/**
* Get maximum index length.
*/
private function detectMaxIndexLength(): int
{
$tableEngine = $this->getTableEngine();
if (!$tableEngine) {
return self::DEFAULT_INDEX_LIMIT;
}
return match ($tableEngine) {
'InnoDB' => 3072,
default => 1000,
};
}
/**
* 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';
}
}

View File

@@ -0,0 +1,155 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils\Database\Schema\ColumnPreparators;
use Doctrine\DBAL\Types\Types;
use Espo\Core\Utils\Database\Schema\Column;
use Espo\Core\Utils\Database\Schema\ColumnPreparator;
use Espo\Core\Utils\Util;
use Espo\ORM\Defs\AttributeDefs;
use Espo\ORM\Defs\Params\AttributeParam;
use Espo\ORM\Entity;
class PostgresqlColumnPreparator implements ColumnPreparator
{
private const PARAM_DB_TYPE = AttributeParam::DB_TYPE;
private const PARAM_DEFAULT = AttributeParam::DEFAULT;
private const PARAM_NOT_NULL = AttributeParam::NOT_NULL;
private const PARAM_AUTOINCREMENT = 'autoincrement';
private const PARAM_PRECISION = 'precision';
private const PARAM_SCALE = 'scale';
/** @var string[] */
private array $textTypeList = [
Entity::TEXT,
Entity::JSON_OBJECT,
Entity::JSON_ARRAY,
];
/** @var array<string, string> */
private array $columnTypeMap = [
Entity::BOOL => Types::BOOLEAN,
Entity::INT => Types::INTEGER,
Entity::VARCHAR => Types::STRING,
// DBAL reverse engineers as blob.
Types::BINARY => Types::BLOB,
];
public function __construct() {}
public function prepare(AttributeDefs $defs): Column
{
$dbType = $defs->getParam(self::PARAM_DB_TYPE);
$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);
$columnType = $dbType ?? $type;
if (in_array($type, $this->textTypeList) && !$dbType) {
$columnType = Types::TEXT;
}
$columnType = $this->columnTypeMap[$columnType] ?? $columnType;
$columnName = Util::toUnderScore($defs->getName());
$column = Column::create($columnName, strtolower($columnType));
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);
}
switch ($type) {
case Entity::TEXT:
$column = $column->withDefault(null);
break;
case Entity::JSON_ARRAY:
$default = is_array($default) ? json_encode($default) : null;
$column = $column->withDefault($default);
break;
case Entity::BOOL:
$default = intval($default ?? false);
$column = $column->withDefault($default);
break;
}
if ($type !== Entity::ID && $autoincrement) {
$column = $column
->withNotNull()
->withUnsigned();
}
return $column;
// @todo Revise. Comparator would detect the column as changed if charset is set.
/*if (
!in_array($columnType, [
Types::STRING,
Types::TEXT,
])
) {
return $column;
}
return $column->withCharset('UTF8');*/
}
}

View File

@@ -0,0 +1,375 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils\Database\Schema;
use Doctrine\DBAL\Exception as DbalException;
use Doctrine\DBAL\Schema\Column as Column;
use Doctrine\DBAL\Schema\ColumnDiff;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\DBAL\Schema\SchemaDiff;
use Doctrine\DBAL\Schema\TableDiff;
use Doctrine\DBAL\Types\TextType;
use Doctrine\DBAL\Types\Type;
use Doctrine\DBAL\Types\Types;
use Espo\Core\Utils\Database\Dbal\Types\LongtextType;
use Espo\Core\Utils\Database\Dbal\Types\MediumtextType;
class DiffModifier
{
/**
* @param RebuildMode::* $mode
* @throws DbalException
*/
public function modify(
SchemaDiff $diff,
Schema $schema,
bool $secondRun = false,
string $mode = RebuildMode::SOFT
): bool {
$reRun = false;
$isHard = $mode === RebuildMode::HARD;
$diff = $this->handleRemovedSequences($diff, $schema);
$diff->removedTables = [];
foreach ($diff->changedTables as $tableDiff) {
$reRun = $this->amendTableDiff($tableDiff, $secondRun, $isHard) || $reRun;
}
return $reRun;
}
/**
* @throws DbalException
*/
private function amendTableDiff(TableDiff $tableDiff, bool $secondRun, bool $isHard): bool
{
$reRun = false;
/**
* @todo Leave only for MariaDB?
* MariaDB supports RENAME INDEX as of v10.5.
* Find out how long does it take to rename for different databases.
*/
if (!$isHard) {
// Prevent index renaming as an operation may take a lot of time.
$tableDiff->renamedIndexes = [];
}
foreach ($tableDiff->removedColumns as $name => $column) {
$reRun = $this->moveRemovedAutoincrementColumnToChanged($tableDiff, $column, $name) || $reRun;
}
if (!$isHard) {
// Prevent column removal to prevent data loss.
$tableDiff->removedColumns = [];
}
// Prevent column renaming as a not desired behavior.
foreach ($tableDiff->renamedColumns as $renamedColumn) {
$addedName = strtolower($renamedColumn->getName());
$tableDiff->addedColumns[$addedName] = $renamedColumn;
}
$tableDiff->renamedColumns = [];
foreach ($tableDiff->addedColumns as $column) {
// Suppress autoincrement as need having a unique index first.
$reRun = $this->amendAddedColumnAutoincrement($column) || $reRun;
}
foreach ($tableDiff->changedColumns as $name => $columnDiff) {
if (!$isHard) {
// Prevent decreasing length for string columns to prevent data loss.
$this->amendColumnDiffLength($tableDiff, $columnDiff, $name);
// Prevent longtext => mediumtext to prevent data loss.
$this->amendColumnDiffTextType($tableDiff, $columnDiff, $name);
// Prevent changing collation.
$this->amendColumnDiffCollation($tableDiff, $columnDiff, $name);
// Prevent changing charset.
$this->amendColumnDiffCharset($tableDiff, $columnDiff, $name);
}
// Prevent setting autoincrement in first run.
if (!$secondRun) {
$reRun = $this->amendColumnDiffAutoincrement($tableDiff, $columnDiff, $name) || $reRun;
}
}
return $reRun;
}
private function amendColumnDiffLength(TableDiff $tableDiff, ColumnDiff $columnDiff, string $name): void
{
$fromColumn = $columnDiff->fromColumn;
$column = $columnDiff->column;
if (!$fromColumn) {
return;
}
if (!in_array('length', $columnDiff->changedProperties)) {
return;
}
$fromLength = $fromColumn->getLength() ?? 255;
$length = $column->getLength() ?? 255;
if ($fromLength <= $length) {
return;
}
$column->setLength($fromLength);
self::unsetChangedColumnProperty($tableDiff, $columnDiff, $name, 'length');
}
/**
* @throws DbalException
*/
private function amendColumnDiffTextType(TableDiff $tableDiff, ColumnDiff $columnDiff, string $name): void
{
$fromColumn = $columnDiff->fromColumn;
$column = $columnDiff->column;
if (!$fromColumn) {
return;
}
if (!in_array('type', $columnDiff->changedProperties)) {
return;
}
$fromType = $fromColumn->getType();
$type = $column->getType();
if (
!$fromType instanceof TextType ||
!$type instanceof TextType
) {
return;
}
$typePriority = [
Types::TEXT,
MediumtextType::NAME,
LongtextType::NAME,
];
$fromIndex = array_search($fromType->getName(), $typePriority);
$index = array_search($type->getName(), $typePriority);
if ($index >= $fromIndex) {
return;
}
$column->setType(Type::getType($fromType->getName()));
self::unsetChangedColumnProperty($tableDiff, $columnDiff, $name, 'type');
}
private function amendColumnDiffCollation(TableDiff $tableDiff, ColumnDiff $columnDiff, string $name): void
{
$fromColumn = $columnDiff->fromColumn;
$column = $columnDiff->column;
if (!$fromColumn) {
return;
}
if (!in_array('collation', $columnDiff->changedProperties)) {
return;
}
$fromCollation = $fromColumn->getPlatformOption('collation');
if (!$fromCollation) {
return;
}
$column->setPlatformOption('collation', $fromCollation);
self::unsetChangedColumnProperty($tableDiff, $columnDiff, $name, 'collation');
}
private function amendColumnDiffCharset(TableDiff $tableDiff, ColumnDiff $columnDiff, string $name): void
{
$fromColumn = $columnDiff->fromColumn;
$column = $columnDiff->column;
if (!$fromColumn) {
return;
}
if (!in_array('charset', $columnDiff->changedProperties)) {
return;
}
$fromCharset = $fromColumn->getPlatformOption('charset');
if (!$fromCharset) {
return;
}
$column->setPlatformOption('charset', $fromCharset);
self::unsetChangedColumnProperty($tableDiff, $columnDiff, $name, 'charset');
}
private function amendColumnDiffAutoincrement(TableDiff $tableDiff, ColumnDiff $columnDiff, string $name): bool
{
$fromColumn = $columnDiff->fromColumn;
$column = $columnDiff->column;
if (!$fromColumn) {
return false;
}
if (!in_array('autoincrement', $columnDiff->changedProperties)) {
return false;
}
$column
->setAutoincrement(false)
->setNotnull(false)
->setDefault(null);
if ($name === 'id') {
$column->setNotnull(true);
}
self::unsetChangedColumnProperty($tableDiff, $columnDiff, $name, 'autoincrement');
return true;
}
private function amendAddedColumnAutoincrement(Column $column): bool
{
if (!$column->getAutoincrement()) {
return false;
}
$column
->setAutoincrement(false)
->setNotnull(false)
->setDefault(null);
return true;
}
private function moveRemovedAutoincrementColumnToChanged(TableDiff $tableDiff, Column $column, string $name): bool
{
if (!$column->getAutoincrement()) {
return false;
}
$newColumn = clone $column;
$newColumn
->setAutoincrement(false)
->setNotnull(false)
->setDefault(null);
$changedProperties = [
'autoincrement',
'notnull',
'default',
];
$tableDiff->changedColumns[$name] = new ColumnDiff($name, $newColumn, $changedProperties, $column);
foreach ($tableDiff->removedIndexes as $indexName => $index) {
if ($index->getColumns() === [$name]) {
unset($tableDiff->removedIndexes[$indexName]);
}
}
return true;
}
private static function unsetChangedColumnProperty(
TableDiff $tableDiff,
ColumnDiff $columnDiff,
string $name,
string $property
): void {
if (count($columnDiff->changedProperties) === 1) {
unset($tableDiff->changedColumns[$name]);
}
$columnDiff->changedProperties = array_diff($columnDiff->changedProperties, [$property]);
}
/**
* DBAL does not handle autoincrement columns that are not primary keys,
* making them dropped.
*/
private function handleRemovedSequences(SchemaDiff $diff, Schema $schema): SchemaDiff
{
$droppedSequences = $diff->getDroppedSequences();
if ($droppedSequences === []) {
return $diff;
}
foreach ($droppedSequences as $i => $sequence) {
foreach ($schema->getTables() as $table) {
$namespace = $table->getNamespaceName();
$tableName = $table->getShortestName($namespace);
foreach ($table->getColumns() as $column) {
if (!$column->getAutoincrement()) {
continue;
}
$sequenceName = $sequence->getShortestName($namespace);
$tableSequenceName = sprintf('%s_%s_seq', $tableName, $column->getShortestName($namespace));
if ($tableSequenceName !== $sequenceName) {
continue;
}
unset($droppedSequences[$i]);
continue 3;
}
}
}
$diff->removedSequences = array_values($droppedSequences);
return $diff;
}
}

View File

@@ -0,0 +1,41 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils\Database\Schema;
use Espo\Core\Utils\Database\Orm\Defs\EntityDefs;
use Espo\ORM\Defs\EntityDefs as OrmEntityDefs;
/**
* Modifies definitions before building a schema.
*/
interface EntityDefsModifier
{
public function modify(OrmEntityDefs $entityDefs): EntityDefs;
}

View File

@@ -0,0 +1,69 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils\Database\Schema\EntityDefsModifiers;
use Espo\Core\Utils\Database\Orm\Defs\AttributeDefs;
use Espo\Core\Utils\Database\Orm\Defs\EntityDefs;
use Espo\Core\Utils\Database\Schema\EntityDefsModifier;
use Espo\ORM\Defs\EntityDefs as OrmEntityDefs;
use Espo\ORM\Defs\Params\AttributeParam;
use Espo\ORM\Type\AttributeType;
/**
* A single JSON column instead of multiple field columns.
*/
class JsonData implements EntityDefsModifier
{
public function modify(OrmEntityDefs $entityDefs): EntityDefs
{
$sourceIdAttribute = $entityDefs->getAttribute('id');
$idAttribute = AttributeDefs::create('id')
->withType(AttributeType::ID);
$length = $sourceIdAttribute->getLength();
$dbType = $sourceIdAttribute->getParam(AttributeParam::DB_TYPE);
if ($length) {
$idAttribute = $idAttribute->withLength($length);
}
if ($dbType) {
$idAttribute = $idAttribute->withDbType($dbType);
}
return EntityDefs::create()
->withAttribute($idAttribute)
->withAttribute(
AttributeDefs::create('data')
->withType(AttributeType::JSON_OBJECT)
);
}
}

View File

@@ -0,0 +1,77 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils\Database\Schema;
use Doctrine\DBAL\Types\Type;
use Espo\Core\Utils\Database\ConfigDataProvider;
use Espo\Core\Utils\Metadata;
class MetadataProvider
{
public function __construct(
private ConfigDataProvider $configDataProvider,
private Metadata $metadata
) {}
private function getPlatform(): string
{
return $this->configDataProvider->getPlatform();
}
/**
* @return class-string<RebuildAction>[]
*/
public function getPreRebuildActionClassNameList(): array
{
/** @var class-string<RebuildAction>[] */
return $this->metadata
->get(['app', 'databasePlatforms', $this->getPlatform(), 'preRebuildActionClassNameList']) ?? [];
}
/**
* @return class-string<RebuildAction>[]
*/
public function getPostRebuildActionClassNameList(): array
{
/** @var class-string<RebuildAction>[] */
return $this->metadata
->get(['app', 'databasePlatforms', $this->getPlatform(), 'postRebuildActionClassNameList']) ?? [];
}
/**
* @return array<string, class-string<Type>>
*/
public function getDbalTypeClassNameMap(): array
{
/** @var array<string, class-string<Type>> */
return $this->metadata
->get(['app', 'databasePlatforms', $this->getPlatform(), 'dbalTypeClassNameMap']) ?? [];
}
}

View File

@@ -0,0 +1,37 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils\Database\Schema;
use Doctrine\DBAL\Schema\Schema as DbalSchema;
interface RebuildAction
{
public function process(DbalSchema $oldSchema, DbalSchema $newSchema): void;
}

View File

@@ -0,0 +1,96 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils\Database\Schema\RebuildActions;
use Doctrine\DBAL\Exception as DbalException;
use Doctrine\DBAL\Schema\Schema as DbalSchema;
use Espo\Core\Utils\Database\Helper;
use Espo\Core\Utils\Database\Schema\RebuildAction;
use Espo\Core\Utils\Log;
use Exception;
class PrepareForFulltextIndex implements RebuildAction
{
public function __construct(
private Helper $helper,
private Log $log
) {}
/**
* @throws DbalException
*/
public function process(DbalSchema $oldSchema, DbalSchema $newSchema): void
{
if ($oldSchema->getTables() === []) {
return;
}
$connection = $this->helper->getDbalConnection();
$pdo = $this->helper->getPDO();
foreach ($newSchema->getTables() as $table) {
$tableName = $table->getName();
$indexes = $table->getIndexes();
foreach ($indexes as $index) {
if (!$index->hasFlag('fulltext')) {
continue;
}
$columns = $index->getColumns();
foreach ($columns as $columnName) {
$sql = "SHOW FULL COLUMNS FROM `" . $tableName . "` WHERE Field = " . $pdo->quote($columnName);
try {
/** @var array{Type: string, Collation: string} $row */
$row = $connection->fetchAssociative($sql);
} catch (Exception) {
continue;
}
switch (strtoupper($row['Type'])) {
case 'LONGTEXT':
$alterSql =
"ALTER TABLE `{$tableName}` " .
"MODIFY `{$columnName}` MEDIUMTEXT COLLATE " . $row['Collation'];
$this->log->info('SCHEMA, Execute Query: ' . $alterSql);
$connection->executeQuery($alterSql);
break;
}
}
}
}
}
}

View File

@@ -0,0 +1,36 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils\Database\Schema;
class RebuildMode
{
public const SOFT = 'soft';
public const HARD = 'hard';
}

View File

@@ -0,0 +1,255 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils\Database\Schema;
use Doctrine\DBAL\Connection as DbalConnection;
use Doctrine\DBAL\Exception as DbalException;
use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Schema\AbstractSchemaManager;
use Doctrine\DBAL\Schema\Comparator;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\DBAL\Schema\SchemaDiff;
use Doctrine\DBAL\Schema\SchemaException;
use Doctrine\DBAL\Types\Type;
use Espo\Core\Binding\BindingContainerBuilder;
use Espo\Core\InjectableFactory;
use Espo\Core\Utils\Database\Helper;
use Espo\Core\Utils\Log;
use Espo\Core\Utils\Metadata\OrmMetadataData;
use Throwable;
/**
* A database schema manager.
*/
class SchemaManager
{
/** @var AbstractSchemaManager<AbstractPlatform> */
private AbstractSchemaManager $schemaManager;
private Comparator $comparator;
private Builder $builder;
/**
* @throws DbalException
*/
public function __construct(
private OrmMetadataData $ormMetadataData,
private Log $log,
private Helper $helper,
private MetadataProvider $metadataProvider,
private DiffModifier $diffModifier,
private InjectableFactory $injectableFactory
) {
$this->schemaManager = $this->getDbalConnection()
->getDatabasePlatform()
->createSchemaManager($this->getDbalConnection());
// Not using a platform specific comparator as it unsets a collation and charset if
// they match a table default.
//$this->comparator = $this->schemaManager->createComparator();
$this->comparator = new Comparator($this->getPlatform());
$this->initFieldTypes();
$this->builder = $this->injectableFactory->createWithBinding(
Builder::class,
BindingContainerBuilder::create()
->bindInstance(Helper::class, $this->helper)
->build()
);
}
public function getDatabaseHelper(): Helper
{
return $this->helper;
}
/**
* @throws DbalException
*/
private function getPlatform(): AbstractPlatform
{
return $this->getDbalConnection()->getDatabasePlatform();
}
private function getDbalConnection(): DbalConnection
{
return $this->helper->getDbalConnection();
}
/**
* @throws DbalException
*/
private function initFieldTypes(): void
{
foreach ($this->metadataProvider->getDbalTypeClassNameMap() as $type => $className) {
Type::hasType($type) ?
Type::overrideType($type, $className) :
Type::addType($type, $className);
$this->getDbalConnection()
->getDatabasePlatform()
->registerDoctrineTypeMapping($type, $type);
}
}
/**
* Rebuild database schema. Creates and alters needed tables and columns.
* Does not remove columns, does not decrease column lengths.
*
* @param ?string[] $entityTypeList Specific entity types.
* @param RebuildMode::* $mode A mode.
* @throws SchemaException
* @throws DbalException
* @todo Catch and re-throw exceptions.
*/
public function rebuild(?array $entityTypeList = null, string $mode = RebuildMode::SOFT): bool
{
$fromSchema = $this->introspectSchema();
$schema = $this->builder->build($this->ormMetadataData->getData(), $entityTypeList);
try {
$this->processPreRebuildActions($fromSchema, $schema);
} catch (Throwable $e) {
$this->log->alert('Rebuild database pre-rebuild error: '. $e->getMessage());
return false;
}
$diff = $this->comparator->compareSchemas($fromSchema, $schema);
$needReRun = $this->diffModifier->modify($diff, $schema, false, $mode);
$sql = $this->composeDiffSql($diff);
$result = $this->runSql($sql);
if (!$result) {
return false;
}
if ($needReRun) {
// Needed to handle auto-increment column creation/removal/change.
// As an auto-increment column requires having a unique index, but
// Doctrine DBAL does not handle this.
$intermediateSchema = $this->introspectSchema();
$schema = $this->builder->build($this->ormMetadataData->getData(), $entityTypeList);
$diff = $this->comparator->compareSchemas($intermediateSchema, $schema);
$this->diffModifier->modify($diff, $schema, true);
$sql = $this->composeDiffSql($diff);
$result = $this->runSql($sql);
}
if (!$result) {
return false;
}
try {
$this->processPostRebuildActions($fromSchema, $schema);
} catch (Throwable $e) {
$this->log->alert('Rebuild database post-rebuild error: ' . $e->getMessage());
return false;
}
return true;
}
/**
* @param string[] $queries
* @return bool
*/
private function runSql(array $queries): bool
{
$result = true;
$connection = $this->getDbalConnection();
foreach ($queries as $sql) {
$this->log->info('Schema, query: '. $sql);
try {
$connection->executeQuery($sql);
} catch (Throwable $e) {
$this->log->alert('Rebuild database error: ' . $e->getMessage());
$result = false;
}
}
return $result;
}
/**
* Introspect and return a current database schema.
*
* @throws DbalException
*/
private function introspectSchema(): Schema
{
return $this->schemaManager->introspectSchema();
}
/**
* @return string[]
* @throws DbalException
*/
private function composeDiffSql(SchemaDiff $diff): array
{
return $this->getPlatform()->getAlterSchemaSQL($diff);
}
private function processPreRebuildActions(Schema $actualSchema, Schema $schema): void
{
$binding = BindingContainerBuilder::create()
->bindInstance(Helper::class, $this->helper)
->build();
foreach ($this->metadataProvider->getPreRebuildActionClassNameList() as $className) {
$action = $this->injectableFactory->createWithBinding($className, $binding);
$action->process($actualSchema, $schema);
}
}
private function processPostRebuildActions(Schema $actualSchema, Schema $schema): void
{
$binding = BindingContainerBuilder::create()
->bindInstance(Helper::class, $this->helper)
->build();
foreach ($this->metadataProvider->getPostRebuildActionClassNameList() as $className) {
$action = $this->injectableFactory->createWithBinding($className, $binding);
$action->process($actualSchema, $schema);
}
}
}

View File

@@ -0,0 +1,64 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils\Database\Schema;
use Espo\Core\InjectableFactory;
use Espo\Core\Utils\Database\Helper;
use Doctrine\DBAL\Schema\SchemaException;
class SchemaManagerProxy
{
private ?SchemaManager $schemaManager = null;
public function __construct(private InjectableFactory $injectableFactory) {}
private function getSchemaManager(): SchemaManager
{
$this->schemaManager ??= $this->injectableFactory->create(SchemaManager::class);
return $this->schemaManager;
}
/**
* @param ?string[] $entityTypeList
* @param RebuildMode::* $mode
* @throws SchemaException
*/
public function rebuild(?array $entityTypeList = null, string $mode = RebuildMode::SOFT): bool
{
return $this->getSchemaManager()->rebuild($entityTypeList, $mode);
}
public function getDatabaseHelper(): Helper
{
return $this->getSchemaManager()->getDatabaseHelper();
}
}

View File

@@ -0,0 +1,237 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils\Database\Schema;
use Espo\Core\ORM\Type\FieldType;
use Espo\Core\Utils\Util;
use Espo\ORM\Defs\IndexDefs;
use Espo\ORM\Defs\Params\AttributeParam;
use Espo\ORM\Defs\Params\EntityParam;
use Espo\ORM\Defs\Params\IndexParam;
class Utils
{
/**
* Get indexes in specific format.
* @deprecated
*
* @param array<string, mixed> $defs
* @param string[] $ignoreFlags @todo Remove parameter?
* @return array<string, array<string, mixed>>
*/
public static function getIndexes(array $defs, array $ignoreFlags = []): array
{
$indexList = [];
foreach ($defs as $entityType => $entityParams) {
$indexes = $entityParams[EntityParam::INDEXES] ?? [];
foreach ($indexes as $indexName => $indexParams) {
$indexDefs = IndexDefs::fromRaw($indexParams, $indexName);
$tableIndexName = $indexParams[IndexParam::KEY] ?? null;
if (!$tableIndexName) {
continue;
}
$columns = $indexDefs->getColumnList();
$flags = $indexDefs->getFlagList();
if ($flags !== []) {
$skipIndex = false;
foreach ($ignoreFlags as $ignoreFlag) {
if (($flagKey = array_search($ignoreFlag, $flags)) !== false) {
unset($flags[$flagKey]);
$skipIndex = true;
}
}
if ($skipIndex && empty($flags)) {
continue;
}
$indexList[$entityType][$tableIndexName][IndexParam::FLAGS] = $flags;
}
if ($columns !== []) {
$indexType = self::getIndexTypeByIndexDefs($indexDefs);
// @todo Revise, may to be removed.
$indexList[$entityType][$tableIndexName][IndexParam::TYPE] = $indexType;
$indexList[$entityType][$tableIndexName][IndexParam::COLUMNS] = array_map(
fn ($item) => Util::toUnderScore($item),
$columns
);
}
}
}
/** @var array<string, array<string, mixed>> */
return $indexList; /** @phpstan-ignore-line */
}
private static function getIndexTypeByIndexDefs(IndexDefs $indexDefs): string
{
if ($indexDefs->isUnique()) {
return 'unique';
}
if (in_array('fulltext', $indexDefs->getFlagList())) {
return 'fulltext';
}
return 'index';
}
/**
* @deprecated
*
* @param array<string, mixed> $ormMeta
* @param int $indexMaxLength
* @param ?array<string, mixed> $indexList
* @param int $characterLength
* @return array<string, mixed>
*/
public static function getFieldListExceededIndexMaxLength(
array $ormMeta,
$indexMaxLength = 1000,
?array $indexList = null,
$characterLength = 4
) {
$permittedFieldTypeList = [
FieldType::VARCHAR,
];
$fields = [];
if (!isset($indexList)) {
$indexList = self::getIndexes($ormMeta, ['fulltext']);
}
foreach ($indexList as $entityName => $indexes) {
foreach ($indexes as $indexName => $indexParams) {
$columnList = $indexParams['columns'];
$indexLength = 0;
foreach ($columnList as $columnName) {
$fieldName = Util::toCamelCase($columnName);
if (!isset($ormMeta[$entityName]['fields'][$fieldName])) {
continue;
}
$indexLength += self::getFieldLength(
$ormMeta[$entityName]['fields'][$fieldName],
$characterLength
);
}
if ($indexLength > $indexMaxLength) {
foreach ($columnList as $columnName) {
$fieldName = Util::toCamelCase($columnName);
if (!isset($ormMeta[$entityName]['fields'][$fieldName])) {
continue;
}
$fieldType = self::getFieldType($ormMeta[$entityName]['fields'][$fieldName]);
if (in_array($fieldType, $permittedFieldTypeList)) {
if (!isset($fields[$entityName]) || !in_array($fieldName, $fields[$entityName])) {
$fields[$entityName][] = $fieldName;
}
}
}
}
}
}
return $fields;
}
/**
* @param array<string, mixed> $ormFieldDefs
* @param int $characterLength
* @return int
*/
private static function getFieldLength(array $ormFieldDefs, $characterLength = 4)
{
$length = 0;
if (isset($ormFieldDefs[AttributeParam::NOT_STORABLE]) && $ormFieldDefs[AttributeParam::NOT_STORABLE]) {
return $length;
}
$defaultLength = [
'datetime' => 8,
'time' => 4,
'int' => 4,
'bool' => 1,
'float' => 4,
'varchar' => 255,
];
$type = self::getDbFieldType($ormFieldDefs);
$length = $defaultLength[$type] ?? $length;
switch ($type) {
case 'varchar':
$length = $length * $characterLength;
break;
}
return $length;
}
/**
* @param array<string, mixed> $ormFieldDefs
* @return string
*/
private static function getDbFieldType(array $ormFieldDefs)
{
return $ormFieldDefs[AttributeParam::DB_TYPE] ?? $ormFieldDefs['type'];
}
/**
* @param array<string, mixed> $ormFieldDefs
*/
private static function getFieldType(array $ormFieldDefs): string
{
return $ormFieldDefs['type'] ?? self::getDbFieldType($ormFieldDefs);
}
}

View File

@@ -0,0 +1,310 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils;
use Carbon\Carbon;
use Espo\Core\Field\Date;
use Espo\Core\Field\DateTime as DateTimeField;
use DateTime as DateTimeStd;
use DateTimeImmutable;
use DateTimeZone;
use Exception;
use RuntimeException;
/**
* Util for a date-time formatting and conversion.
* Available as 'dateTime' service.
*/
class DateTime
{
public const SYSTEM_DATE_TIME_FORMAT = 'Y-m-d H:i:s';
public const SYSTEM_DATE_FORMAT = 'Y-m-d';
private string $dateFormat;
private string $timeFormat;
private DateTimeZone $timezone;
private string $language;
public function __construct(
?string $dateFormat = 'YYYY-MM-DD',
?string $timeFormat = 'HH:mm',
?string $timeZone = 'UTC',
?string $language = 'en_US'
) {
$this->dateFormat = $dateFormat ?? 'YYYY-MM-DD';
$this->timeFormat = $timeFormat ?? 'HH:mm';
$this->language = $language ?? 'en_US';
try {
$this->timezone = new DateTimeZone($timeZone ?? 'UTC');
} catch (Exception $e) {
throw new RuntimeException($e->getMessage());
}
}
/**
* Get a default date format.
*/
public function getDateFormat(): string
{
return $this->dateFormat;
}
/**
* Get a default date-time format.
*/
public function getDateTimeFormat(): string
{
return $this->dateFormat . ' ' . $this->timeFormat;
}
/**
* Convert a system date.
*
* @param string $string A system date.
* @param string|null $format A target format. If not specified then the default format will be used.
* @param string|null $language A language. If not specified then the default language will be used.
* @throws RuntimeException If it could not parse.
*/
public function convertSystemDate(
string $string,
?string $format = null,
?string $language = null
): string {
$dateTime = DateTimeStd::createFromFormat('Y-m-d', $string);
if ($dateTime === false) {
throw new RuntimeException("Could not parse date `$string`.");
}
$carbon = Carbon::instance($dateTime);
$carbon->locale($language ?? $this->language);
return $carbon->isoFormat($format ?? $this->getDateFormat());
}
/**
* Convert a system date-time.
*
* @param string $string A system date-time.
* @param ?string $timezone A target timezone. If not specified then the default timezone will be used.
* @param ?string $format A target format. If not specified then the default format will be used.
* @param ?string $language A language. If not specified then the default language will be used.
* @throws RuntimeException If it could not parse.
*/
public function convertSystemDateTime(
string $string,
?string $timezone = null,
?string $format = null,
?string $language = null
): string {
if (strlen($string) === 16) {
$string .= ':00';
}
$dateTime = DateTimeStd::createFromFormat('Y-m-d H:i:s', $string);
if ($dateTime === false) {
throw new RuntimeException("Could not parse date-time `$string`.");
}
try {
$tz = $timezone ? new DateTimeZone($timezone) : $this->timezone;
} catch (Exception $e) {
throw new RuntimeException($e->getMessage());
}
$dateTime->setTimezone($tz);
$carbon = Carbon::instance($dateTime);
$carbon->locale($language ?? $this->language);
return $carbon->isoFormat($format ?? $this->getDateTimeFormat());
}
/**
* Get a current date.
*
* @param ?string $timezone If not specified then the default will be used.
* @param ?string $format If not specified then the default will be used.
*/
public function getTodayString(?string $timezone = null, ?string $format = null): string
{
try {
$tz = $timezone ? new DateTimeZone($timezone) : $this->timezone;
} catch (Exception $e) {
throw new RuntimeException($e->getMessage());
}
$dateTime = new DateTimeStd();
$dateTime->setTimezone($tz);
$carbon = Carbon::instance($dateTime);
$carbon->locale($this->language);
return $carbon->isoFormat($format ?? $this->getDateFormat());
}
/**
* Get a current date-time.
*
* @param ?string $timezone If not specified then the default will be used.
* @param ?string $format If not specified then the default will be used.
*/
public function getNowString(?string $timezone = null, ?string $format = null): string
{
try {
$tz = $timezone ? new DateTimeZone($timezone) : $this->timezone;
} catch (Exception $e) {
throw new RuntimeException($e->getMessage());
}
$dateTime = new DateTimeStd();
$dateTime->setTimezone($tz);
$carbon = Carbon::instance($dateTime);
$carbon->locale($this->language);
return $carbon->isoFormat($format ?? $this->getDateTimeFormat());
}
/**
* Get a current date-time in the system format in UTC timezone.
*/
public static function getSystemNowString(): string
{
return date(self::SYSTEM_DATE_TIME_FORMAT);
}
public static function getSystemTodayString(): string
{
return date(self::SYSTEM_DATE_FORMAT);
}
/**
* Convert a format to the system format.
* Example: `YYYY-MM-DD` will be converted to `Y-m-d`.
*/
public static function convertFormatToSystem(string $format): string
{
$map = [
'MM' => 'm',
'DD' => 'd',
'YYYY' => 'Y',
'HH' => 'H',
'mm' => 'i',
'hh' => 'h',
'A' => 'A',
'a' => 'a',
'ss' => 's',
];
return str_replace(
array_keys($map),
array_values($map),
$format
);
}
/**
* Get the default time zone.
*
* @since 8.0.0
*/
public function getTimezone(): DateTimeZone
{
return $this->timezone;
}
/**
* Get a today's date according the default time zone.
*
* @since 8.0.0
*/
public function getToday(): Date
{
$string = (new DateTimeImmutable)
->setTimezone($this->timezone)
->format(self::SYSTEM_DATE_FORMAT);
return Date::fromString($string);
}
/**
* Get a now date-time with the default time zone applied.
*
* @since 8.0.0
*/
public function getNow(): DateTimeField
{
return DateTimeField::createNow()
->withTimezone($this->timezone);
}
/**
* @deprecated Use `SYSTEM_DATE_TIME_FORMAT constant`.
*/
public function getInternalDateTimeFormat(): string
{
return self::SYSTEM_DATE_TIME_FORMAT;
}
/**
* @deprecated Use `SYSTEM_DATE_FORMAT constant`.
*/
public function getInternalDateFormat(): string
{
return self::SYSTEM_DATE_FORMAT;
}
/**
* @deprecated Use `convertSystemDate`.
* @param string $string
*/
public function convertSystemDateToGlobal($string): string
{
return $this->convertSystemDate($string);
}
/**
* @deprecated Use `convertSystemDateTime`.
*/
public function convertSystemDateTimeToGlobal(string $string): string
{
return $this->convertSystemDateTime($string);
}
}

View File

@@ -0,0 +1,69 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils\DateTime;
use Espo\Core\Utils\Config\ApplicationConfig;
use Espo\Core\Utils\DateTime;
use Espo\Core\InjectableFactory;
use Espo\ORM\EntityManager;
use Espo\Entities\User;
use Espo\Entities\Preferences;
class DateTimeFactory
{
public function __construct(
private InjectableFactory $injectableFactory,
private EntityManager $entityManager,
private ApplicationConfig $applicationConfig,
) {}
public function createWithUserTimeZone(User $user): DateTime
{
$preferences = $this->entityManager->getEntityById(Preferences::ENTITY_TYPE, $user->getId());
$timeZone = $this->applicationConfig->getTimeZone();
if ($preferences) {
$timeZone = $preferences->get('timeZone') ? $preferences->get('timeZone') : $timeZone;
}
return $this->createWithTimeZone($timeZone);
}
public function createWithTimeZone(string $timeZone): DateTime
{
return $this->injectableFactory->createWith(DateTime::class, [
'timeZone' => $timeZone,
'dateFormat' => $this->applicationConfig->getDateFormat(),
'timeFormat' => $this->applicationConfig->getTimeFormat(),
'language' => $this->applicationConfig->getLanguage(),
]);
}
}

Some files were not shown because too many files have changed in this diff Show More