From e7cb4e5f6634f94c76185d067bb5dd06897c2aa5 Mon Sep 17 00:00:00 2001 From: Yurii Date: Thu, 2 Jul 2026 16:30:01 +0300 Subject: [PATCH 01/87] Jobs refactoring --- application/Espo/Binding.php | 7 + .../Espo/Core/ApplicationRunners/Cron.php | 10 +- application/Espo/Core/Job/CronUtil.php | 84 +++++++++++ .../Core/Job/Exceptions/TooFrequentRun.php | 35 +++++ application/Espo/Core/Job/JobManager.php | 127 +++++------------ application/Espo/Core/Job/QueueProcessor.php | 115 +-------------- .../Espo/Core/Job/QueueProcessor/Params.php | 28 ---- .../QueueProcessors/DefaultQueueProcessor.php | 74 ++++++++++ .../ProcessPoolQueueProcessor.php | 131 ++++++++++++++++++ .../SequentialQueueProcessor.php | 83 +++++++++++ application/Espo/Core/Job/QueueUtil.php | 34 ++--- application/Espo/Entities/Job.php | 9 +- tests/integration/Espo/Core/Job/JobTest.php | 2 +- .../Core/Job/QueueProcessorParamsTest.php | 21 +-- 14 files changed, 493 insertions(+), 267 deletions(-) create mode 100644 application/Espo/Core/Job/CronUtil.php create mode 100644 application/Espo/Core/Job/Exceptions/TooFrequentRun.php create mode 100644 application/Espo/Core/Job/QueueProcessor/QueueProcessors/DefaultQueueProcessor.php create mode 100644 application/Espo/Core/Job/QueueProcessor/QueueProcessors/ProcessPoolQueueProcessor.php create mode 100644 application/Espo/Core/Job/QueueProcessor/QueueProcessors/SequentialQueueProcessor.php diff --git a/application/Espo/Binding.php b/application/Espo/Binding.php index 7b326b6735e..105bb0ead92 100644 --- a/application/Espo/Binding.php +++ b/application/Espo/Binding.php @@ -265,6 +265,13 @@ private function bindCore(Binder $binder): void 'Espo\\Core\\Job\\JobScheduler\\Creator', 'Espo\\Core\\Job\\JobScheduler\\Creators\\EntityCreator', ); + + $binder->inContext('Espo\\Core\\Job\\JobManager', function ($binder) { + $binder->bindImplementation( + 'Espo\\Core\\Job\\QueueProcessor', + 'Espo\\Core\\Job\\QueueProcessor\\QueueProcessors\\DefaultQueueProcessor', + ); + }); } private function bindMisc(Binder $binder): void diff --git a/application/Espo/Core/ApplicationRunners/Cron.php b/application/Espo/Core/ApplicationRunners/Cron.php index c203f8f24ec..461b70529e3 100644 --- a/application/Espo/Core/ApplicationRunners/Cron.php +++ b/application/Espo/Core/ApplicationRunners/Cron.php @@ -29,7 +29,9 @@ namespace Espo\Core\ApplicationRunners; +use Espo\Core\Application\Exceptions\RunnerException; use Espo\Core\Application\Runner; +use Espo\Core\Job\Exceptions\TooFrequentRun; use Espo\Core\Job\JobManager; use Espo\Core\Utils\Config\SystemConfig; use Espo\Core\Utils\Log; @@ -56,6 +58,12 @@ public function run(): void return; } - $this->jobManager->process(); + try { + $this->jobManager->prepare(); + } catch (TooFrequentRun $e) { + throw new RunnerException('Too frequent run.', previous: $e); + } + + $this->jobManager->processMainQueue(); } } diff --git a/application/Espo/Core/Job/CronUtil.php b/application/Espo/Core/Job/CronUtil.php new file mode 100644 index 00000000000..7da33f3574e --- /dev/null +++ b/application/Espo/Core/Job/CronUtil.php @@ -0,0 +1,84 @@ +. + * + * 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\Job; + +use Espo\Core\Utils\File\Manager as FileManager; +use RuntimeException; + +/** + * @internal + */ +class CronUtil +{ + protected string $lastRunTimeFile = 'data/cache/application/cronLastRunTime.php'; + + public function __construct( + private FileManager $fileManager, + private ConfigDataProvider $configDataProvider, + ) {} + + public function checkLastRunTime(): bool + { + $currentTime = time(); + $lastRunTime = $this->getLastRunTime(); + + $cronMinInterval = $this->configDataProvider->getCronMinInterval(); + + if ($currentTime > ($lastRunTime + $cronMinInterval)) { + return true; + } + + return false; + } + + private function getLastRunTime(): int + { + if ($this->fileManager->isFile($this->lastRunTimeFile)) { + try { + $data = $this->fileManager->getPhpContents($this->lastRunTimeFile); + } catch (RuntimeException) { + $data = null; + } + + if (is_array($data) && isset($data['time'])) { + return (int) $data['time']; + } + } + + return time() - $this->configDataProvider->getCronMinInterval() - 1; + } + + public function updateLastRunTime(): void + { + $data = ['time' => time()]; + + $this->fileManager->putPhpContents($this->lastRunTimeFile, $data, false, true); + } +} diff --git a/application/Espo/Core/Job/Exceptions/TooFrequentRun.php b/application/Espo/Core/Job/Exceptions/TooFrequentRun.php new file mode 100644 index 00000000000..bf10989d49c --- /dev/null +++ b/application/Espo/Core/Job/Exceptions/TooFrequentRun.php @@ -0,0 +1,35 @@ +. + * + * 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\Job\Exceptions; + +use Exception; + +class TooFrequentRun extends Exception +{} diff --git a/application/Espo/Core/Job/JobManager.php b/application/Espo/Core/Job/JobManager.php index ed9fb64ceee..73d9eab47bb 100644 --- a/application/Espo/Core/Job/JobManager.php +++ b/application/Espo/Core/Job/JobManager.php @@ -29,12 +29,11 @@ namespace Espo\Core\Job; +use Espo\Core\Job\Exceptions\TooFrequentRun; use Espo\Core\Job\QueueProcessor\Params; -use Espo\Core\Utils\File\Manager as FileManager; -use Espo\Core\Utils\Log; +use Espo\Core\Job\QueueProcessor\QueueProcessors\SequentialQueueProcessor; use Espo\Entities\Job as JobEntity; -use RuntimeException; use Throwable; /** @@ -42,46 +41,31 @@ */ class JobManager { - private bool $useProcessPool = false; - protected string $lastRunTimeFile = 'data/cache/application/cronLastRunTime.php'; - public function __construct( - private FileManager $fileManager, private JobRunner $jobRunner, - private Log $log, private ScheduleProcessor $scheduleProcessor, private QueueUtil $queueUtil, - private AsyncPoolFactory $asyncPoolFactory, private QueueProcessor $queueProcessor, - private ConfigDataProvider $configDataProvider - ) { - if ($this->configDataProvider->runInParallel()) { - if ($this->asyncPoolFactory->isSupported()) { - $this->useProcessPool = true; - } else { - $this->log->warning("Enabled `jobRunInParallel` parameter requires pcntl and posix extensions."); - } - } - } + private ConfigDataProvider $configDataProvider, + private SequentialQueueProcessor $sequentialQueueProcessor, + private CronUtil $cronUtil, + ) {} /** - * Process jobs. Jobs will be created according scheduling. Then pending jobs will be processed. - * This method supposed to be called on every Cron run or loop iteration of the Daemon. + * Jobs are be created according scheduling of scheduled jobs. + * This method is meant to be called on every Cron run or loop iteration of the Daemon. + * + * @throws TooFrequentRun */ - public function process(): void + public function prepare(): void { - if (!$this->checkLastRunTime()) { - $this->log->info('JobManager: Skip job processing. Too frequent execution.'); - - return; + if (!$this->cronUtil->checkLastRunTime()) { + throw new TooFrequentRun('JobManager: Skip job processing. Too frequent execution.'); } - $this->updateLastRunTime(); - $this->queueUtil->markJobsFailed(); - $this->queueUtil->updateFailedJobAttempts(); - $this->scheduleProcessor->process(); - $this->queueUtil->removePendingJobDuplicates(); - $this->processMainQueue(); + $this->cronUtil->updateLastRunTime(); + + $this->processPrepare(); } /** @@ -89,14 +73,11 @@ public function process(): void */ public function processQueue(string $queue, int $limit): void { - $params = Params - ::create() + $params = Params::create() ->withQueue($queue) - ->withLimit($limit) - ->withUseProcessPool(false) - ->withNoLock(true); + ->withLimit($limit); - $this->queueProcessor->process($params); + $this->sequentialQueueProcessor->process($params); } /** @@ -104,31 +85,27 @@ public function processQueue(string $queue, int $limit): void */ public function processGroup(string $group, int $limit): void { - $params = Params - ::create() + $params = Params::create() ->withGroup($group) - ->withLimit($limit) - ->withUseProcessPool(false) - ->withNoLock(true); + ->withLimit($limit); - $this->queueProcessor->process($params); + $this->sequentialQueueProcessor->process($params); } - private function processMainQueue(): void + /** + * Process the main job queue. + */ + public function processMainQueue(): void { $limit = $this->configDataProvider->getMaxPortion(); - $params = Params - ::create() - ->withUseProcessPool($this->useProcessPool) + $params = Params::create() ->withLimit($limit); - $subQueueParams = [ + $params = $params->withSubQueueParamsList([ $params->withWeight(0.5), $params->withQueue(QueueName::M0)->withWeight(0.5), - ]; - - $params = $params->withSubQueueParamsList($subQueueParams); + ]); $this->queueProcessor->process($params); } @@ -151,47 +128,11 @@ public function runJob(JobEntity $job): void $this->jobRunner->runThrowingException($job); } - /** - * @todo Move to a separate class. - */ - private function getLastRunTime(): int - { - if ($this->fileManager->isFile($this->lastRunTimeFile)) { - try { - $data = $this->fileManager->getPhpContents($this->lastRunTimeFile); - } catch (RuntimeException) { - $data = null; - } - - if (is_array($data) && isset($data['time'])) { - return (int) $data['time']; - } - } - - return time() - $this->configDataProvider->getCronMinInterval() - 1; - } - - /** - * @todo Move to a separate class. - */ - private function updateLastRunTime(): void + private function processPrepare(): void { - $data = ['time' => time()]; - - $this->fileManager->putPhpContents($this->lastRunTimeFile, $data, false, true); - } - - private function checkLastRunTime(): bool - { - $currentTime = time(); - $lastRunTime = $this->getLastRunTime(); - - $cronMinInterval = $this->configDataProvider->getCronMinInterval(); - - if ($currentTime > ($lastRunTime + $cronMinInterval)) { - return true; - } - - return false; + $this->queueUtil->markJobsFailed(); + $this->queueUtil->updateFailedJobAttempts(); + $this->scheduleProcessor->process(); + $this->queueUtil->removePendingJobDuplicates(); } } diff --git a/application/Espo/Core/Job/QueueProcessor.php b/application/Espo/Core/Job/QueueProcessor.php index cd105eb6078..07f579ea2e8 100644 --- a/application/Espo/Core/Job/QueueProcessor.php +++ b/application/Espo/Core/Job/QueueProcessor.php @@ -29,117 +29,12 @@ namespace Espo\Core\Job; -use Espo\Core\Job\QueueProcessor\Picker; -use Espo\Entities\Job as JobEntity; use Espo\Core\Job\QueueProcessor\Params; -use Espo\Core\ORM\EntityManager; -use Espo\Core\Utils\System; -use Espo\Core\Job\Job\Status; -use Spatie\Async\Pool as AsyncPool; - -class QueueProcessor +/** + * @since 10.1.0 + */ +interface QueueProcessor { - private bool $noTableLocking; - - public function __construct( - private QueueUtil $queueUtil, - private JobRunner $jobRunner, - private AsyncPoolFactory $asyncPoolFactory, - private EntityManager $entityManager, - private Picker $picker, - ConfigDataProvider $configDataProvider - ) { - $this->noTableLocking = $configDataProvider->noTableLocking(); - } - - public function process(Params $params): void - { - $pool = $params->useProcessPool() ? - $this->asyncPoolFactory->create() : null; - - foreach ($this->picker->pick($params) as $job) { - $this->processJob($params, $job, $pool); - } - - $pool?->wait(); - } - - private function processJob(Params $params, JobEntity $job, ?AsyncPool $pool = null): void - { - $noLock = $params->noLock(); - $lockTable = $job->getScheduledJobId() && !$noLock && !$this->noTableLocking; - - if ($lockTable) { - // MySQL doesn't allow to lock non-existent rows. We resort to locking an entire table. - $this->entityManager->getLocker()->lockExclusive(JobEntity::ENTITY_TYPE); - } - - $skip = $this->toSkip($noLock, $job); - - if ($skip) { - if ($lockTable) { - $this->entityManager->getLocker()->rollback(); - } - - return; - } - - $this->prepareJob($job, $pool); - - $this->entityManager->saveEntity($job); - - if ($lockTable) { - $this->entityManager->getLocker()->commit(); - } - - $this->runJob($job, $pool); - } - - private function toSkip(bool $noLock, JobEntity $job): bool - { - $skip = !$noLock && !$this->queueUtil->isJobPending($job->getId()); - - if ( - !$skip && - $job->getScheduledJobId() && - $this->queueUtil->isScheduledJobRunning( - $job->getScheduledJobId(), - $job->getTargetId(), - $job->getTargetType(), - $job->getTargetGroup() - ) - ) { - $skip = true; - } - - return $skip; - } - - private function prepareJob(JobEntity $job, ?AsyncPool $pool): void - { - $job->setStartedAtNow(); - - if ($pool) { - $job->setStatus(Status::READY); - - return; - } - - $job->setStatus(Status::RUNNING); - $job->setPid(System::getPid()); - } - - private function runJob(JobEntity $job, ?AsyncPool $pool): void - { - if (!$pool) { - $this->jobRunner->run($job); - - return; - } - - $task = new JobTask($job->getId()); - - $pool->add($task); - } + public function process(Params $params): void; } diff --git a/application/Espo/Core/Job/QueueProcessor/Params.php b/application/Espo/Core/Job/QueueProcessor/Params.php index a6bc9e05f1b..ce025e4fe49 100644 --- a/application/Espo/Core/Job/QueueProcessor/Params.php +++ b/application/Espo/Core/Job/QueueProcessor/Params.php @@ -31,8 +31,6 @@ class Params { - private bool $useProcessPool = false; - private bool $noLock = false; private ?string $queue = null; private ?string $group = null; private int $limit = 0; @@ -40,22 +38,6 @@ class Params private ?array $subQueueParamsList = null; private float $weight = 1.0; - public function withUseProcessPool(bool $useProcessPool): self - { - $obj = clone $this; - $obj->useProcessPool = $useProcessPool; - - return $obj; - } - - public function withNoLock(bool $noLock): self - { - $obj = clone $this; - $obj->noLock = $noLock; - - return $obj; - } - public function withQueue(?string $queue): self { $obj = clone $this; @@ -99,16 +81,6 @@ public function withSubQueueParamsList(?array $subQueueParamsList): self return $obj; } - public function useProcessPool(): bool - { - return $this->useProcessPool; - } - - public function noLock(): bool - { - return $this->noLock; - } - public function getQueue(): ?string { return $this->queue; diff --git a/application/Espo/Core/Job/QueueProcessor/QueueProcessors/DefaultQueueProcessor.php b/application/Espo/Core/Job/QueueProcessor/QueueProcessors/DefaultQueueProcessor.php new file mode 100644 index 00000000000..4859d3c5234 --- /dev/null +++ b/application/Espo/Core/Job/QueueProcessor/QueueProcessors/DefaultQueueProcessor.php @@ -0,0 +1,74 @@ +. + * + * 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\Job\QueueProcessor\QueueProcessors; + +use Espo\Core\Job\AsyncPoolFactory; +use Espo\Core\Job\ConfigDataProvider; +use Espo\Core\Job\QueueProcessor; +use Espo\Core\Utils\Log; +use Espo\Core\Job\QueueProcessor\Params; + +/** + * @internal + */ +class DefaultQueueProcessor implements QueueProcessor +{ + public function __construct( + private ProcessPoolQueueProcessor $processPoolQueueProcessor, + private SequentialQueueProcessor $sequentialQueueProcessor, + private AsyncPoolFactory $asyncPoolFactory, + private Log $log, + private ConfigDataProvider $configDataProvider, + ) {} + + public function process(Params $params): void + { + if ($this->inParallel()) { + $this->processPoolQueueProcessor->process($params); + } else { + $this->sequentialQueueProcessor->process($params); + } + } + + private function inParallel(): bool + { + if (!$this->configDataProvider->runInParallel()) { + return false; + } + + if (!$this->asyncPoolFactory->isSupported()) { + $this->log->warning("Enabled `jobRunInParallel` parameter requires pcntl and posix extensions."); + + return false; + } + + return true; + } +} diff --git a/application/Espo/Core/Job/QueueProcessor/QueueProcessors/ProcessPoolQueueProcessor.php b/application/Espo/Core/Job/QueueProcessor/QueueProcessors/ProcessPoolQueueProcessor.php new file mode 100644 index 00000000000..0de8b7454f3 --- /dev/null +++ b/application/Espo/Core/Job/QueueProcessor/QueueProcessors/ProcessPoolQueueProcessor.php @@ -0,0 +1,131 @@ +. + * + * 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\Job\QueueProcessor\QueueProcessors; + +use Espo\Core\Job\AsyncPoolFactory; +use Espo\Core\Job\ConfigDataProvider; +use Espo\Core\Job\Job\Status; +use Espo\Core\Job\JobTask; +use Espo\Core\Job\QueueProcessor; +use Espo\Core\Job\QueueProcessor\Params; +use Espo\Core\Job\QueueProcessor\Picker; +use Espo\Core\Job\QueueUtil; +use Espo\Core\ORM\EntityManager; +use Espo\Entities\Job; +use Spatie\Async\Pool as AsyncPool; + +class ProcessPoolQueueProcessor implements QueueProcessor +{ + public function __construct( + private QueueUtil $queueUtil, + private AsyncPoolFactory $asyncPoolFactory, + private EntityManager $entityManager, + private Picker $picker, + private ConfigDataProvider $configDataProvider, + ) {} + + public function process(Params $params): void + { + $noLock = $this->skipLock($params); + + $pool = $this->asyncPoolFactory->create(); + + foreach ($this->picker->pick($params) as $job) { + $this->processJob($noLock, $job, $pool); + } + + $pool->wait(); + } + + private function processJob(bool $noLock, Job $job, AsyncPool $pool): void + { + $lockTable = !$noLock && $job->getScheduledJobId(); + + if ($lockTable) { + // MySQL doesn't allow to lock non-existent rows. We resort to locking an entire table. + $this->entityManager->getLocker()->lockExclusive(Job::ENTITY_TYPE); + } + + $skip = $this->toSkip($noLock, $job); + + if ($skip) { + if ($lockTable) { + $this->entityManager->getLocker()->rollback(); + } + + return; + } + + $this->prepareJob($job); + + if ($lockTable) { + $this->entityManager->getLocker()->commit(); + } + + $this->runJob($job, $pool); + } + + private function toSkip(bool $noLock, Job $job): bool + { + if (!$noLock && !$this->queueUtil->isJobPending($job)) { + return true; + } + + if ($this->queueUtil->isScheduledJobRunning($job)) { + return true; + } + + return false; + } + + private function prepareJob(Job $job): void + { + $job + ->setStartedAtNow() + ->setStatus(Status::READY); + + $this->entityManager->saveEntity($job); + } + + private function runJob(Job $job, AsyncPool $pool): void + { + $task = new JobTask($job->getId()); + + $pool->add($task); + } + + private function skipLock(Params $params): bool + { + return + $params->getGroup() !== null || + $params->getQueue() !== null || + $this->configDataProvider->noTableLocking(); + } +} diff --git a/application/Espo/Core/Job/QueueProcessor/QueueProcessors/SequentialQueueProcessor.php b/application/Espo/Core/Job/QueueProcessor/QueueProcessors/SequentialQueueProcessor.php new file mode 100644 index 00000000000..a8507cacd59 --- /dev/null +++ b/application/Espo/Core/Job/QueueProcessor/QueueProcessors/SequentialQueueProcessor.php @@ -0,0 +1,83 @@ +. + * + * 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\Job\QueueProcessor\QueueProcessors; + +use Espo\Core\Job\Job\Status; +use Espo\Core\Job\JobRunner; +use Espo\Core\Job\QueueProcessor; +use Espo\Core\Job\QueueProcessor\Params; +use Espo\Core\Job\QueueProcessor\Picker; +use Espo\Core\Job\QueueUtil; +use Espo\Core\ORM\EntityManager; +use Espo\Core\Utils\System; +use Espo\Entities\Job; + +class SequentialQueueProcessor implements QueueProcessor +{ + public function __construct( + private QueueUtil $queueUtil, + private JobRunner $jobRunner, + private EntityManager $entityManager, + private Picker $picker, + ) {} + + public function process(Params $params): void + { + foreach ($this->picker->pick($params) as $job) { + $this->processJob($job); + } + } + + private function processJob(Job $job): void + { + if ($this->toSkip($job)) { + return; + } + + $this->prepareJob($job); + + $this->jobRunner->run($job); + } + + private function toSkip(Job $job): bool + { + return $this->queueUtil->isScheduledJobRunning($job); + } + + private function prepareJob(Job $job): void + { + $job + ->setStartedAtNow() + ->setStatus(Status::RUNNING) + ->setPid(System::getPid()); + + $this->entityManager->saveEntity($job); + } +} diff --git a/application/Espo/Core/Job/QueueUtil.php b/application/Espo/Core/Job/QueueUtil.php index 88d9056cc54..0e781e4040b 100644 --- a/application/Espo/Core/Job/QueueUtil.php +++ b/application/Espo/Core/Job/QueueUtil.php @@ -37,32 +37,30 @@ use Espo\Core\Utils\System; use Espo\Core\Job\Job\Status; use Espo\Entities\Job as JobEntity; - -use DateTime; use Espo\ORM\Collection; use Espo\ORM\Name\Attribute; +use DateTime; use Exception; use LogicException; class QueueUtil { - private const NOT_EXISTING_PROCESS_PERIOD = 300; - private const READY_NOT_STARTED_PERIOD = 60; + private const int NOT_EXISTING_PROCESS_PERIOD = 300; + private const int READY_NOT_STARTED_PERIOD = 60; public function __construct( private Config $config, private EntityManager $entityManager, private ScheduleUtil $scheduleUtil, - private MetadataProvider $metadataProvider + private MetadataProvider $metadataProvider, ) {} - public function isJobPending(string $id): bool + public function isJobPending(JobEntity $job): bool { - /** @var ?JobEntity $job */ $job = $this->entityManager ->getRDBRepositoryByClass(JobEntity::class) - ->select([Attribute::ID, 'status']) - ->where([Attribute::ID => $id]) + ->select([Attribute::ID, JobEntity::FIELD_STATUS]) + ->where([Attribute::ID => $job->getId()]) ->forUpdate() ->findOne(); @@ -111,16 +109,20 @@ public function getPendingJobs(Params $params): Collection return $builder->sth()->find(); } - public function isScheduledJobRunning( - string $scheduledJobId, - ?string $targetId = null, - ?string $targetType = null, - ?string $targetGroup = null - ): bool { + public function isScheduledJobRunning(JobEntity $job): bool + { + $scheduledJobId = $job->getScheduledJobId(); + $targetId = $job->getTargetId(); + $targetType = $job->getTargetType(); + $targetGroup = $job->getTargetGroup(); + + if (!$scheduledJobId) { + return false; + } $where = [ 'scheduledJobId' => $scheduledJobId, - 'status' => [ + JobEntity::FIELD_STATUS => [ Status::RUNNING, Status::READY, ], diff --git a/application/Espo/Entities/Job.php b/application/Espo/Entities/Job.php index 27047295f38..aa553551b5d 100644 --- a/application/Espo/Entities/Job.php +++ b/application/Espo/Entities/Job.php @@ -42,12 +42,17 @@ class Job extends Entity { public const ENTITY_TYPE = 'Job'; + /** + * @since v10.1.0 + */ + public const string FIELD_STATUS = 'status'; + /** * Get a status. */ public function getStatus(): string { - return $this->get('status'); + return $this->get(self::FIELD_STATUS); } /** @@ -171,7 +176,7 @@ public function getFailedAttempts(): int */ public function setStatus(string $status): self { - return $this->set('status', $status); + return $this->set(self::FIELD_STATUS, $status); } /** diff --git a/tests/integration/Espo/Core/Job/JobTest.php b/tests/integration/Espo/Core/Job/JobTest.php index 1dd5394b660..114372a95db 100644 --- a/tests/integration/Espo/Core/Job/JobTest.php +++ b/tests/integration/Espo/Core/Job/JobTest.php @@ -120,7 +120,7 @@ public function testProcessQueueGroupAll(): void 'group' => 'group-1', ]); - $this->jobManager->process(); + $this->jobManager->processMainQueue(); $job1Reloaded = $this->entityManager->getEntityById('Job', $job1->getId()); $job2Reloaded = $this->entityManager->getEntityById('Job', $job2->getId()); diff --git a/tests/unit/Espo/Core/Job/QueueProcessorParamsTest.php b/tests/unit/Espo/Core/Job/QueueProcessorParamsTest.php index d0adac1bd1e..12e83d3e40b 100644 --- a/tests/unit/Espo/Core/Job/QueueProcessorParamsTest.php +++ b/tests/unit/Espo/Core/Job/QueueProcessorParamsTest.php @@ -29,11 +29,10 @@ namespace tests\unit\Espo\Core\Job; -use Espo\Core\{ - Job\QueueProcessor\Params, -}; +use Espo\Core\Job\QueueProcessor\Params; +use PHPUnit\Framework\TestCase; -class QueueProcessorParamsTest extends \PHPUnit\Framework\TestCase +class QueueProcessorParamsTest extends TestCase { protected function setUp() : void { @@ -41,33 +40,23 @@ protected function setUp() : void public function testParams1() { - $params = \Espo\Core\Job\QueueProcessor\Params + $params = Params ::create() ->withLimit(10); - $this->assertFalse($params->useProcessPool()); - $this->assertFalse($params->noLock()); - $this->assertEquals(10, $params->getLimit()); - $this->assertNull($params->getQueue()); } public function testParams2() { - $params = \Espo\Core\Job\QueueProcessor\Params + $params = Params ::create() ->withLimit(10) - ->withUseProcessPool(true) - ->withNoLock(true) ->withGroup('group-0') ->withQueue('q0'); - $this->assertTrue($params->useProcessPool()); - $this->assertTrue($params->noLock()); - $this->assertEquals('q0', $params->getQueue()); - $this->assertEquals('group-0', $params->getGroup()); } } From b5f46e8422d8bb627bfe920e528bc626fb39887e Mon Sep 17 00:00:00 2001 From: Yurii Date: Sat, 4 Jul 2026 14:51:06 +0300 Subject: [PATCH 02/87] Test fix --- tests/integration/Espo/Core/Job/JobTest.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/integration/Espo/Core/Job/JobTest.php b/tests/integration/Espo/Core/Job/JobTest.php index 114372a95db..e320b55fc35 100644 --- a/tests/integration/Espo/Core/Job/JobTest.php +++ b/tests/integration/Espo/Core/Job/JobTest.php @@ -103,6 +103,9 @@ public function testProcessQueueNoGroup(): void $this->assertEquals(Status::SUCCESS, $jobReloaded->getStatus()); } + /** + * @noinspection PhpUnhandledExceptionInspection + */ public function testProcessQueueGroupAll(): void { $job1 = $this->entityManager->createEntity('Job', [ @@ -120,6 +123,8 @@ public function testProcessQueueGroupAll(): void 'group' => 'group-1', ]); + + $this->jobManager->prepare(); $this->jobManager->processMainQueue(); $job1Reloaded = $this->entityManager->getEntityById('Job', $job1->getId()); From 36b47065a0d6e5f47c41fda148f9d488bcda7b49 Mon Sep 17 00:00:00 2001 From: Yurii Kuznietsov Date: Fri, 10 Jul 2026 16:58:26 +0300 Subject: [PATCH 03/87] Job worker processing (#3723) --- application/Espo/Binding.php | 14 ++ .../Classes/ConsoleCommands/JobEnqueue.php | 122 ++++++++++ .../Classes/ConsoleCommands/JobPrepare.php | 94 ++++++++ .../Classes/ConsoleCommands/JobWorker.php | 86 +++++++ .../Espo/Core/ApplicationRunners/Cron.php | 6 +- .../Core/Job/Job/Jobs/ProcessJobQueueE0.php | 2 + .../Core/Job/Job/Jobs/ProcessJobQueueQ0.php | 2 + .../Core/Job/Job/Jobs/ProcessJobQueueQ1.php | 2 + application/Espo/Core/Job/JobManager.php | 29 --- .../Core/Job/Preparator/CollectionHelper.php | 14 +- .../Espo/Core/Job/PrepareProcessor.php | 74 ++++++ .../Espo/Core/Job/PrepareProcessor/Params.php | 40 ++++ .../Espo/Core/Job/Processing/Consumer.php | 42 ++++ .../Core/Job/Processing/Consumer/Params.php | 38 +++ .../Core/Job/Processing/EnqueueDaemon.php | 217 ++++++++++++++++++ .../Job/Processing/EnqueueDaemon/Params.php | 43 ++++ .../Espo/Core/Job/Processing/JobProvider.php | 65 ++++++ .../Core/Job/Processing/PrepareDaemon.php | 113 +++++++++ .../Job/Processing/PrepareDaemon/Params.php | 42 ++++ .../Espo/Core/Job/Processing/Publisher.php | 45 ++++ .../Core/Job/Processing/Publisher/Params.php | 37 +++ .../Processing/RabbitMq/ConnectionFactory.php | 58 +++++ .../Core/Job/Processing/RabbitMq/Consumer.php | 198 ++++++++++++++++ .../Job/Processing/RabbitMq/Publisher.php | 117 ++++++++++ .../Core/Job/Processing/RabbitMq/Util.php | 44 ++++ .../Core/Job/Processing/Util/ExitPolicy.php | 50 ++++ .../Core/Job/Processing/Util/ExitSetup.php | 46 ++++ .../Espo/Core/Job/Processing/WorkerDaemon.php | 67 ++++++ .../Job/Processing/WorkerDaemon/Params.php | 38 +++ application/Espo/Core/Job/QueueName.php | 14 +- .../ProcessPoolQueueProcessor.php | 1 + application/Espo/Core/Job/QueueUtil.php | 4 +- .../Espo/Core/Job/ScheduleProcessor.php | 9 +- .../Core/Job/ScheduleProcessor/Params.php | 40 ++++ application/Espo/Core/Job/ScheduleUtil.php | 41 +++- .../Espo/Core/Utils/Config/StateConfig.php | 47 ++++ .../Core/Utils/Config/StateConfigDirect.php | 52 +++++ application/Espo/Entities/ScheduledJob.php | 15 +- .../Espo/Resources/defaults/systemConfig.php | 1 + .../metadata/app/consoleCommands.json | 31 +++ composer.json | 3 +- composer.lock | 83 ++++++- tests/integration/Espo/Core/Job/JobTest.php | 8 +- 43 files changed, 2025 insertions(+), 69 deletions(-) create mode 100644 application/Espo/Classes/ConsoleCommands/JobEnqueue.php create mode 100644 application/Espo/Classes/ConsoleCommands/JobPrepare.php create mode 100644 application/Espo/Classes/ConsoleCommands/JobWorker.php create mode 100644 application/Espo/Core/Job/PrepareProcessor.php create mode 100644 application/Espo/Core/Job/PrepareProcessor/Params.php create mode 100644 application/Espo/Core/Job/Processing/Consumer.php create mode 100644 application/Espo/Core/Job/Processing/Consumer/Params.php create mode 100644 application/Espo/Core/Job/Processing/EnqueueDaemon.php create mode 100644 application/Espo/Core/Job/Processing/EnqueueDaemon/Params.php create mode 100644 application/Espo/Core/Job/Processing/JobProvider.php create mode 100644 application/Espo/Core/Job/Processing/PrepareDaemon.php create mode 100644 application/Espo/Core/Job/Processing/PrepareDaemon/Params.php create mode 100644 application/Espo/Core/Job/Processing/Publisher.php create mode 100644 application/Espo/Core/Job/Processing/Publisher/Params.php create mode 100644 application/Espo/Core/Job/Processing/RabbitMq/ConnectionFactory.php create mode 100644 application/Espo/Core/Job/Processing/RabbitMq/Consumer.php create mode 100644 application/Espo/Core/Job/Processing/RabbitMq/Publisher.php create mode 100644 application/Espo/Core/Job/Processing/RabbitMq/Util.php create mode 100644 application/Espo/Core/Job/Processing/Util/ExitPolicy.php create mode 100644 application/Espo/Core/Job/Processing/Util/ExitSetup.php create mode 100644 application/Espo/Core/Job/Processing/WorkerDaemon.php create mode 100644 application/Espo/Core/Job/Processing/WorkerDaemon/Params.php create mode 100644 application/Espo/Core/Job/ScheduleProcessor/Params.php create mode 100644 application/Espo/Core/Utils/Config/StateConfig.php create mode 100644 application/Espo/Core/Utils/Config/StateConfigDirect.php diff --git a/application/Espo/Binding.php b/application/Espo/Binding.php index 105bb0ead92..fed1c8966c8 100644 --- a/application/Espo/Binding.php +++ b/application/Espo/Binding.php @@ -272,6 +272,20 @@ private function bindCore(Binder $binder): void 'Espo\\Core\\Job\\QueueProcessor\\QueueProcessors\\DefaultQueueProcessor', ); }); + + $binder->inContext('Espo\\Core\\Job\\Processing\\EnqueueDaemon', function ($binder) { + $binder->bindImplementation( + 'Espo\\Core\\Job\\Processing\\Publisher', + 'Espo\\Core\\Job\\Processing\\RabbitMq\\Publisher', + ); + }); + + $binder->inContext('Espo\\Core\\Job\\Processing\\WorkerDaemon', function ($binder) { + $binder->bindImplementation( + 'Espo\\Core\\Job\\Processing\\Consumer', + 'Espo\\Core\\Job\\Processing\\RabbitMq\\Consumer', + ); + }); } private function bindMisc(Binder $binder): void diff --git a/application/Espo/Classes/ConsoleCommands/JobEnqueue.php b/application/Espo/Classes/ConsoleCommands/JobEnqueue.php new file mode 100644 index 00000000000..822645c91ad --- /dev/null +++ b/application/Espo/Classes/ConsoleCommands/JobEnqueue.php @@ -0,0 +1,122 @@ +. + * + * 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\Classes\ConsoleCommands; + +use Espo\Core\Console\Command; +use Espo\Core\Console\Command\Params; +use Espo\Core\Console\Exceptions\InvalidArgument; +use Espo\Core\Console\IO; +use Espo\Core\Job\Processing\EnqueueDaemon; +use Espo\Core\Job\QueueName; +use RuntimeException; + +/** + * @noinspection PhpUnused + */ +class JobEnqueue implements Command +{ + public function __construct( + private EnqueueDaemon $enqueueDaemon, + ) {} + + public function run(Params $params, IO $io): void + { + $daemonParams = $this->prepareParams($params); + + $this->enqueueDaemon->run($daemonParams); + } + + private function prepareParams(Params $params): EnqueueDaemon\Params + { + $interval = $this->getInterval($params); + $limit = $this->getLimit($params); + $portion = $this->getPortion($params); + $queue = $params->getOption('queue'); + + if ($queue === QueueName::M0) { + throw new InvalidArgument("Queue 'm0' is not allowed. It is processed in the main queue."); + } + + return new EnqueueDaemon\Params( + interval: $interval, + limit: $limit, + portion: $portion, + queue: $queue, + ); + } + + private function getInterval(Params $params): ?float + { + $valueString = $params->getOption('interval'); + + if ($valueString === null) { + return null; + } + + if ( + filter_var($valueString, FILTER_VALIDATE_INT) !== false || + filter_var($valueString, FILTER_VALIDATE_FLOAT) !== false + ) { + return (float) $valueString; + } + + throw new RuntimeException("Bad interval."); + } + + private function getLimit(Params $params): ?int + { + $valueString = $params->getOption('limit'); + + if ($valueString === null) { + return null; + } + + if (filter_var($valueString, FILTER_VALIDATE_INT) !== false) { + return (int) $valueString; + } + + throw new RuntimeException("Bad limit."); + } + + private function getPortion(Params $params): ?int + { + $valueString = $params->getOption('portion'); + + if ($valueString === null) { + return null; + } + + if (filter_var($valueString, FILTER_VALIDATE_INT) !== false) { + return (int) $valueString; + } + + throw new RuntimeException("Bad portion."); + } +} diff --git a/application/Espo/Classes/ConsoleCommands/JobPrepare.php b/application/Espo/Classes/ConsoleCommands/JobPrepare.php new file mode 100644 index 00000000000..1d1d538d700 --- /dev/null +++ b/application/Espo/Classes/ConsoleCommands/JobPrepare.php @@ -0,0 +1,94 @@ +. + * + * 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\Classes\ConsoleCommands; + +use Espo\Core\Console\Command; +use Espo\Core\Console\Command\Params; +use Espo\Core\Console\IO; +use Espo\Core\Job\Processing\PrepareDaemon; +use RuntimeException; + +/** + * @noinspection PhpUnused + */ +class JobPrepare implements Command +{ + public function __construct( + private PrepareDaemon $prepareDaemon, + ) {} + + public function run(Params $params, IO $io): void + { + $interval = $this->getInterval($params); + $limit = $this->getLimit($params); + $skipQueues = $params->hasFlag('sq'); + + $daemonParams = new PrepareDaemon\Params( + interval: $interval, + limit: $limit, + skipQueues: $skipQueues, + ); + + $this->prepareDaemon->run($daemonParams); + } + + private function getInterval(Params $params): ?float + { + $intervalString = $params->getOption('interval'); + + if ($intervalString === null) { + return null; + } + + if ( + filter_var($intervalString, FILTER_VALIDATE_INT) !== false || + filter_var($intervalString, FILTER_VALIDATE_FLOAT) !== false + ) { + return (float) $intervalString; + } else { + throw new RuntimeException("Bad interval."); + } + } + + private function getLimit(Params $params): ?int + { + $limitString = $params->getOption('limit'); + + if ($limitString === null) { + return null; + } + + if (filter_var($limitString, FILTER_VALIDATE_INT) !== false) { + return (int) $limitString; + } else { + throw new RuntimeException("Bad limit."); + } + } +} diff --git a/application/Espo/Classes/ConsoleCommands/JobWorker.php b/application/Espo/Classes/ConsoleCommands/JobWorker.php new file mode 100644 index 00000000000..2733b188deb --- /dev/null +++ b/application/Espo/Classes/ConsoleCommands/JobWorker.php @@ -0,0 +1,86 @@ +. + * + * 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\Classes\ConsoleCommands; + +use Espo\Core\Console\Command; +use Espo\Core\Console\Command\Params; +use Espo\Core\Console\Exceptions\InvalidArgument; +use Espo\Core\Console\IO; +use Espo\Core\Job\Processing\WorkerDaemon; +use Espo\Core\Job\QueueName; +use RuntimeException; + +/** + * @noinspection PhpUnused + */ +class JobWorker implements Command +{ + public function __construct( + private WorkerDaemon $workerDaemon, + ) {} + + public function run(Params $params, IO $io): void + { + $daemonParams = $this->prepareParams($params); + + $this->workerDaemon->run($daemonParams); + } + + private function prepareParams(Params $params): WorkerDaemon\Params + { + $limit = $this->getLimit($params); + + $queue = $params->getOption('queue'); + + if ($queue === QueueName::M0) { + throw new InvalidArgument("Queue 'm0' is not allowed. It is processed in the main queue."); + } + + return new WorkerDaemon\Params( + limit: $limit, + queue: $queue, + ); + } + + private function getLimit(Params $params): ?int + { + $limitString = $params->getOption('limit'); + + if ($limitString === null) { + return null; + } + + if (filter_var($limitString, FILTER_VALIDATE_INT) !== false) { + return (int) $limitString; + } + + throw new RuntimeException("Bad limit."); + } +} diff --git a/application/Espo/Core/ApplicationRunners/Cron.php b/application/Espo/Core/ApplicationRunners/Cron.php index 461b70529e3..ce855bb6d94 100644 --- a/application/Espo/Core/ApplicationRunners/Cron.php +++ b/application/Espo/Core/ApplicationRunners/Cron.php @@ -33,6 +33,7 @@ use Espo\Core\Application\Runner; use Espo\Core\Job\Exceptions\TooFrequentRun; use Espo\Core\Job\JobManager; +use Espo\Core\Job\PrepareProcessor; use Espo\Core\Utils\Config\SystemConfig; use Espo\Core\Utils\Log; @@ -45,9 +46,10 @@ class Cron implements Runner use SetupSystemUser; public function __construct( + private PrepareProcessor $prepareProcessor, private JobManager $jobManager, private SystemConfig $config, - private Log $log + private Log $log, ) {} public function run(): void @@ -59,7 +61,7 @@ public function run(): void } try { - $this->jobManager->prepare(); + $this->prepareProcessor->process(); } catch (TooFrequentRun $e) { throw new RunnerException('Too frequent run.', previous: $e); } diff --git a/application/Espo/Core/Job/Job/Jobs/ProcessJobQueueE0.php b/application/Espo/Core/Job/Job/Jobs/ProcessJobQueueE0.php index ff34db690e1..e0cdfba02d1 100644 --- a/application/Espo/Core/Job/Job/Jobs/ProcessJobQueueE0.php +++ b/application/Espo/Core/Job/Job/Jobs/ProcessJobQueueE0.php @@ -34,4 +34,6 @@ class ProcessJobQueueE0 extends AbstractQueueJob { protected string $queue = QueueName::E0; + + public const string NAME = 'ProcessJobQueueE0'; } diff --git a/application/Espo/Core/Job/Job/Jobs/ProcessJobQueueQ0.php b/application/Espo/Core/Job/Job/Jobs/ProcessJobQueueQ0.php index 8bba7bc922d..3b059e05495 100644 --- a/application/Espo/Core/Job/Job/Jobs/ProcessJobQueueQ0.php +++ b/application/Espo/Core/Job/Job/Jobs/ProcessJobQueueQ0.php @@ -34,4 +34,6 @@ class ProcessJobQueueQ0 extends AbstractQueueJob { protected string $queue = QueueName::Q0; + + public const string NAME = 'ProcessJobQueueQ0'; } diff --git a/application/Espo/Core/Job/Job/Jobs/ProcessJobQueueQ1.php b/application/Espo/Core/Job/Job/Jobs/ProcessJobQueueQ1.php index 800dd385286..bdf1d6313cc 100644 --- a/application/Espo/Core/Job/Job/Jobs/ProcessJobQueueQ1.php +++ b/application/Espo/Core/Job/Job/Jobs/ProcessJobQueueQ1.php @@ -34,4 +34,6 @@ class ProcessJobQueueQ1 extends AbstractQueueJob { protected string $queue = QueueName::Q1; + + public const string NAME = 'ProcessJobQueueQ1'; } diff --git a/application/Espo/Core/Job/JobManager.php b/application/Espo/Core/Job/JobManager.php index 73d9eab47bb..d6cec719623 100644 --- a/application/Espo/Core/Job/JobManager.php +++ b/application/Espo/Core/Job/JobManager.php @@ -29,7 +29,6 @@ namespace Espo\Core\Job; -use Espo\Core\Job\Exceptions\TooFrequentRun; use Espo\Core\Job\QueueProcessor\Params; use Espo\Core\Job\QueueProcessor\QueueProcessors\SequentialQueueProcessor; use Espo\Entities\Job as JobEntity; @@ -43,31 +42,11 @@ class JobManager { public function __construct( private JobRunner $jobRunner, - private ScheduleProcessor $scheduleProcessor, - private QueueUtil $queueUtil, private QueueProcessor $queueProcessor, private ConfigDataProvider $configDataProvider, private SequentialQueueProcessor $sequentialQueueProcessor, - private CronUtil $cronUtil, ) {} - /** - * Jobs are be created according scheduling of scheduled jobs. - * This method is meant to be called on every Cron run or loop iteration of the Daemon. - * - * @throws TooFrequentRun - */ - public function prepare(): void - { - if (!$this->cronUtil->checkLastRunTime()) { - throw new TooFrequentRun('JobManager: Skip job processing. Too frequent execution.'); - } - - $this->cronUtil->updateLastRunTime(); - - $this->processPrepare(); - } - /** * Process pending jobs from a specific queue. Jobs within a queue are processed one by one. */ @@ -127,12 +106,4 @@ public function runJob(JobEntity $job): void { $this->jobRunner->runThrowingException($job); } - - private function processPrepare(): void - { - $this->queueUtil->markJobsFailed(); - $this->queueUtil->updateFailedJobAttempts(); - $this->scheduleProcessor->process(); - $this->queueUtil->removePendingJobDuplicates(); - } } diff --git a/application/Espo/Core/Job/Preparator/CollectionHelper.php b/application/Espo/Core/Job/Preparator/CollectionHelper.php index 84587e7c7ca..13b74e72e54 100644 --- a/application/Espo/Core/Job/Preparator/CollectionHelper.php +++ b/application/Espo/Core/Job/Preparator/CollectionHelper.php @@ -66,11 +66,13 @@ public function prepare(Collection $collection, Data $data, DateTimeImmutable $e private function prepareItem(Entity $entity, Data $data, DateTimeImmutable $executeTime): void { $running = $this->entityManager - ->getRDBRepository(Job::ENTITY_TYPE) + ->getRDBRepositoryByClass(Job::class) + // Reduces the chance of race condition. + ->forUpdate() ->select(Attribute::ID) ->where([ 'scheduledJobId' => $data->getId(), - 'status' => [ + Job::FIELD_STATUS => [ Status::RUNNING, Status::READY, ], @@ -84,10 +86,10 @@ private function prepareItem(Entity $entity, Data $data, DateTimeImmutable $exec } $countPending = $this->entityManager - ->getRDBRepository(Job::ENTITY_TYPE) + ->getRDBRepositoryByClass(Job::class) ->where([ 'scheduledJobId' => $data->getId(), - 'status' => Status::PENDING, + Job::FIELD_STATUS => Status::PENDING, 'targetType' => $entity->getEntityType(), 'targetId' => $entity->getId(), ]) @@ -97,9 +99,9 @@ private function prepareItem(Entity $entity, Data $data, DateTimeImmutable $exec return; } - $job = $this->entityManager->getNewEntity(Job::ENTITY_TYPE); + $job = $this->entityManager->getRDBRepositoryByClass(Job::class)->getNew(); - $job->set([ + $job->setMultiple([ 'name' => $data->getName(), 'scheduledJobId' => $data->getId(), 'executeTime' => $executeTime->format(DateTime::SYSTEM_DATE_TIME_FORMAT), diff --git a/application/Espo/Core/Job/PrepareProcessor.php b/application/Espo/Core/Job/PrepareProcessor.php new file mode 100644 index 00000000000..3f1141163c9 --- /dev/null +++ b/application/Espo/Core/Job/PrepareProcessor.php @@ -0,0 +1,74 @@ +. + * + * 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\Job; + +use Espo\Core\Job\Exceptions\TooFrequentRun; +use Espo\Core\Job\PrepareProcessor\Params; + +/** + * @since 10.1.0 + */ +class PrepareProcessor +{ + public function __construct( + private QueueUtil $queueUtil, + private ScheduleProcessor $scheduleProcessor, + private CronUtil $cronUtil, + ) {} + + /** + * Jobs are be created according scheduling of scheduled jobs. + * This method is meant to be called on every Cron run or loop iteration of the Daemon. + * + * @throws TooFrequentRun + */ + public function process(Params $params = new Params()): void + { + if (!$this->cronUtil->checkLastRunTime()) { + throw new TooFrequentRun('JobManager: Skip job processing. Too frequent execution.'); + } + + $this->cronUtil->updateLastRunTime(); + + $this->processPrepare($params); + } + + private function processPrepare(Params $params): void + { + $scheduleParams = new ScheduleProcessor\Params( + skipQueues: $params->skipQueues, + ); + + $this->queueUtil->markJobsFailed(); + $this->queueUtil->updateFailedJobAttempts(); + $this->scheduleProcessor->process($scheduleParams); + $this->queueUtil->removePendingJobDuplicates(); + } +} diff --git a/application/Espo/Core/Job/PrepareProcessor/Params.php b/application/Espo/Core/Job/PrepareProcessor/Params.php new file mode 100644 index 00000000000..fcb0628ee5e --- /dev/null +++ b/application/Espo/Core/Job/PrepareProcessor/Params.php @@ -0,0 +1,40 @@ +. + * + * 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\Job\PrepareProcessor; + +/** + * @since 10.1.0 + */ +readonly class Params +{ + public function __construct( + public bool $skipQueues = false, + ) {} +} diff --git a/application/Espo/Core/Job/Processing/Consumer.php b/application/Espo/Core/Job/Processing/Consumer.php new file mode 100644 index 00000000000..6904d1b3ec3 --- /dev/null +++ b/application/Espo/Core/Job/Processing/Consumer.php @@ -0,0 +1,42 @@ +. + * + * 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\Job\Processing; + +use Espo\Core\Job\Processing\Consumer\Params; + +/** + * @since 10.1.0 + */ +interface Consumer +{ + public function start(Params $params): void; + + public function stop(): void; +} diff --git a/application/Espo/Core/Job/Processing/Consumer/Params.php b/application/Espo/Core/Job/Processing/Consumer/Params.php new file mode 100644 index 00000000000..6da9945fae8 --- /dev/null +++ b/application/Espo/Core/Job/Processing/Consumer/Params.php @@ -0,0 +1,38 @@ +. + * + * 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\Job\Processing\Consumer; + +readonly class Params +{ + public function __construct( + public ?int $limit = null, + public ?string $queue = null, + ) {} +} diff --git a/application/Espo/Core/Job/Processing/EnqueueDaemon.php b/application/Espo/Core/Job/Processing/EnqueueDaemon.php new file mode 100644 index 00000000000..4597d3376ba --- /dev/null +++ b/application/Espo/Core/Job/Processing/EnqueueDaemon.php @@ -0,0 +1,217 @@ +. + * + * 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\Job\Processing; + +use Espo\Core\Job\ConfigDataProvider; +use Espo\Core\Job\Job\Status; +use Espo\Core\Job\Processing\Util\ExitPolicy; +use Espo\Core\Job\Processing\Util\ExitSetup; +use Espo\Core\Job\QueueName; +use Espo\Core\Job\QueueProcessor\Params; +use Espo\Core\Job\QueueProcessor\Picker; +use Espo\Core\Job\QueueUtil; +use Espo\Core\Utils\Log; +use Espo\Entities\Job; +use Espo\ORM\EntityManager; +use Espo\ORM\Name\Attribute; +use RuntimeException; +use Throwable; + +/** + * @since 10.1.0 + * + * @internal + */ +class EnqueueDaemon +{ + private const float INTERVAL = 1.0; + + private bool $stopped = false; + + public function __construct( + private Picker $picker, + private ConfigDataProvider $configDataProvider, + private Publisher $publisher, + private EntityManager $entityManager, + private QueueUtil $queueUtil, + private Log $log, + private ExitSetup $exitSetup, + private ExitPolicy $exitPolicy, + ) {} + + public function run(EnqueueDaemon\Params $params): void + { + $interval = $this->getInterval($params); + $limit = $params->limit; + + $pickerParams = $this->getPickerParams($params); + $publisherParams = $this->preparePublisherParams($params); + + $this->publisher->initialize($publisherParams); + + $this->exitSetup->setup(function () { + $this->stopped = true; + }); + + $count = 0; + + while (true) { + $jobs = $this->picker->pick($pickerParams); + + foreach ($jobs as $job) { + $this->processJob($job); + + $count ++; + + if ($this->toForceExit() || $this->toExit($limit, $count)) { + break 2; + } + } + + usleep($interval); + + if ($this->toForceExit()) { + break; + } + } + + $this->publisher->close(); + } + + private function isStopped(): bool + { + return $this->stopped; + } + + private function getPickerParams(EnqueueDaemon\Params $daemonParams): Params + { + $limit = $daemonParams->portion ?? $this->configDataProvider->getMaxPortion(); + + $params = Params::create() + ->withLimit($limit); + + if ($daemonParams->queue !== null) { + return $params->withQueue($daemonParams->queue); + } + + return $params->withSubQueueParamsList([ + $params->withWeight(0.5), + $params->withQueue(QueueName::M0)->withWeight(0.5), + ]); + } + + private function getInterval(EnqueueDaemon\Params $params): int + { + $interval = $params->interval ?? self::INTERVAL; + + return (int) ($interval * 1000000); + } + + private function prepareJob(Job $job): void + { + $job + // Needed for failing not started. + ->setStartedAtNow() + ->setStatus(Status::READY); + + $this->entityManager->saveEntity($job); + } + + private function processJob(Job $job): void + { + $this->entityManager->getTransactionManager()->run(function () use ($job) { + $this->processJobInternal($job); + }); + } + + private function toSkip(Job $job): bool + { + if ($job->getStatus() !== Status::PENDING) { + return true; + } + + if ($this->queueUtil->isScheduledJobRunning($job)) { + return true; + } + + return false; + } + + private function fetchLocked(Job $job): ?Job + { + return $this->entityManager + ->getRDBRepositoryByClass(Job::class) + ->forUpdate() + ->where([Attribute::ID => $job->getId()]) + ->findOne(); + } + + private function processJobInternal(Job $job): void + { + $job = $this->fetchLocked($job); + + if (!$job || $this->toSkip($job)) { + return; + } + + $this->prepareJob($job); + + try { + $this->publisher->publish($job); + } catch (Throwable $e) { + $this->log->error("Enqueue: Could not publish job {id}.", [ + 'id' => $job->getId(), + 'exception' => $e, + ]); + + throw new RuntimeException(previous: $e); + } + } + + private function toExit(?int $limit, int $count): bool + { + return $limit && $count >= $limit; + } + + /** + * @phpstan-impure + */ + private function toForceExit(): bool + { + return $this->isStopped() || $this->exitPolicy->toExit(); + } + + private function preparePublisherParams(EnqueueDaemon\Params $params): Publisher\Params + { + return new Publisher\Params( + queue: $params->queue, + ); + } +} diff --git a/application/Espo/Core/Job/Processing/EnqueueDaemon/Params.php b/application/Espo/Core/Job/Processing/EnqueueDaemon/Params.php new file mode 100644 index 00000000000..0c7830009f2 --- /dev/null +++ b/application/Espo/Core/Job/Processing/EnqueueDaemon/Params.php @@ -0,0 +1,43 @@ +. + * + * 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\Job\Processing\EnqueueDaemon; + +readonly class Params +{ + /** + * @param ?float $interval Interval in seconds. + */ + public function __construct( + public ?float $interval = null, + public ?int $limit = null, + public ?int $portion = null, + public ?string $queue = null, + ) {} +} diff --git a/application/Espo/Core/Job/Processing/JobProvider.php b/application/Espo/Core/Job/Processing/JobProvider.php new file mode 100644 index 00000000000..7972843989f --- /dev/null +++ b/application/Espo/Core/Job/Processing/JobProvider.php @@ -0,0 +1,65 @@ +. + * + * 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\Job\Processing; + +use Espo\Core\Job\Job\Status; +use Espo\Entities\Job; +use Espo\ORM\EntityManager; +use Espo\ORM\Name\Attribute; +use Exception; + +class JobProvider +{ + public function __construct( + private EntityManager $entityManager, + ) {} + + /** + * @throws Exception + */ + public function get(string $id): Job + { + $job = $this->entityManager + ->getRDBRepositoryByClass(Job::class) + ->forUpdate() + ->where([Attribute::ID => $id]) + ->findOne(); + + if (!$job) { + throw new Exception("Job $id not found."); + } + + if ($job->getStatus() !== Status::READY) { + throw new Exception("Job $id is not in status Ready."); + } + + return $job; + } +} diff --git a/application/Espo/Core/Job/Processing/PrepareDaemon.php b/application/Espo/Core/Job/Processing/PrepareDaemon.php new file mode 100644 index 00000000000..61b0497a7ef --- /dev/null +++ b/application/Espo/Core/Job/Processing/PrepareDaemon.php @@ -0,0 +1,113 @@ +. + * + * 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\Job\Processing; + +use Espo\Core\Job\Exceptions\TooFrequentRun; +use Espo\Core\Job\PrepareProcessor; +use Espo\Core\Job\Processing\Util\ExitPolicy; +use Espo\Core\Job\Processing\Util\ExitSetup; + +/** + * @since 10.1.0 + */ +class PrepareDaemon +{ + private const float INTERVAL = 10.0; + + private bool $stopped = false; + + public function __construct( + private ExitSetup $exitSetup, + private ExitPolicy $exitPolicy, + private PrepareProcessor $prepareProcessor, + ) {} + + public function run(PrepareDaemon\Params $params): void + { + $interval = $this->getInterval($params); + + $prepareParams = new PrepareProcessor\Params( + skipQueues: $params->skipQueues, + ); + + $this->exitSetup->setup(function () { + $this->stopped = true; + }); + + $count = 0; + + while (true) { + try { + $this->prepareProcessor->process($prepareParams); + } catch (TooFrequentRun) { + continue; + } + + $count ++; + + if ($this->toForceExit() || $this->toExit($params, $count)) { + break; + } + + usleep($interval); + + if ($this->toForceExit()) { + break; + } + } + + $this->stopped = false; + } + + private function getInterval(PrepareDaemon\Params $params): int + { + $interval = $params->interval ?? self::INTERVAL; + + return (int) ($interval * 1000000); + } + + private function isStopped(): bool + { + return $this->stopped; + } + + private function toExit(PrepareDaemon\Params $params, int $count): bool + { + return $params->limit && $count >= $params->limit; + } + + /** + * @phpstan-impure + */ + private function toForceExit(): bool + { + return $this->isStopped() || $this->exitPolicy->toExit(); + } +} diff --git a/application/Espo/Core/Job/Processing/PrepareDaemon/Params.php b/application/Espo/Core/Job/Processing/PrepareDaemon/Params.php new file mode 100644 index 00000000000..e8b847dce8d --- /dev/null +++ b/application/Espo/Core/Job/Processing/PrepareDaemon/Params.php @@ -0,0 +1,42 @@ +. + * + * 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\Job\Processing\PrepareDaemon; + +readonly class Params +{ + /** + * @param ?float $interval Interval in seconds. + */ + public function __construct( + public ?float $interval = null, + public ?int $limit = null, + public bool $skipQueues = false, + ) {} +} diff --git a/application/Espo/Core/Job/Processing/Publisher.php b/application/Espo/Core/Job/Processing/Publisher.php new file mode 100644 index 00000000000..8d8de6ff5fb --- /dev/null +++ b/application/Espo/Core/Job/Processing/Publisher.php @@ -0,0 +1,45 @@ +. + * + * 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\Job\Processing; + +use Espo\Core\Job\Processing\Publisher\Params; +use Espo\Entities\Job; + +/** + * @since 10.1.0 + */ +interface Publisher +{ + public function initialize(Params $params): void; + + public function publish(Job $job): void; + + public function close(): void; +} diff --git a/application/Espo/Core/Job/Processing/Publisher/Params.php b/application/Espo/Core/Job/Processing/Publisher/Params.php new file mode 100644 index 00000000000..8ead955443e --- /dev/null +++ b/application/Espo/Core/Job/Processing/Publisher/Params.php @@ -0,0 +1,37 @@ +. + * + * 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\Job\Processing\Publisher; + +readonly class Params +{ + public function __construct( + public ?string $queue, + ) {} +} diff --git a/application/Espo/Core/Job/Processing/RabbitMq/ConnectionFactory.php b/application/Espo/Core/Job/Processing/RabbitMq/ConnectionFactory.php new file mode 100644 index 00000000000..2dd94e472a6 --- /dev/null +++ b/application/Espo/Core/Job/Processing/RabbitMq/ConnectionFactory.php @@ -0,0 +1,58 @@ +. + * + * 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\Job\Processing\RabbitMq; + +use Espo\Core\Utils\Config; +use Exception; +use PhpAmqpLib\Connection\AMQPStreamConnection; +use RuntimeException; + +class ConnectionFactory +{ + public function __construct( + private Config $config, + ) {} + + public function create(): AMQPStreamConnection + { + $host = $this->config->get('rabbitMq.host'); + $port = $this->config->get('rabbitMq.port'); + $user = $this->config->get('rabbitMq.user'); + $password = $this->config->get('rabbitMq.password'); + + try { + $connection = new AMQPStreamConnection($host, $port, $user, $password); + } catch (Exception $e) { + throw new RuntimeException("Connection error.", previous: $e); + } + + return $connection; + } +} diff --git a/application/Espo/Core/Job/Processing/RabbitMq/Consumer.php b/application/Espo/Core/Job/Processing/RabbitMq/Consumer.php new file mode 100644 index 00000000000..c9a2e110a55 --- /dev/null +++ b/application/Espo/Core/Job/Processing/RabbitMq/Consumer.php @@ -0,0 +1,198 @@ +. + * + * 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\Job\Processing\RabbitMq; + +use Espo\Core\Job\JobRunner; +use Espo\Core\Job\Processing\JobProvider; +use Espo\Core\Job\Processing\Consumer as ConsumerInterface; +use Espo\Core\Job\Processing\Consumer\Params; +use Espo\Core\Job\Processing\Util\ExitPolicy; +use Espo\Core\Utils\Log; +use Exception; +use PhpAmqpLib\Channel\AMQPChannel; +use PhpAmqpLib\Connection\AMQPStreamConnection; +use PhpAmqpLib\Exception\AMQPTimeoutException; +use PhpAmqpLib\Message\AMQPMessage; +use RuntimeException; +use Throwable; + +class Consumer implements ConsumerInterface +{ + private bool $stopped = false; + + private const float ITERATION_TIMEOUT = 1.0; + + public function __construct( + private ConnectionFactory $connectionFactory, + private JobRunner $jobRunner, + private Log $log, + private JobProvider $jobProvider, + private ExitPolicy $exitPolicy, + ) {} + + public function start(Params $params): void + { + if ($this->stopped) { + $this->stopped = false; + + return; + } + + $queue = Util::composeQueueName($params->queue); + + $connection = $this->connectionFactory->create(); + $channel = $this->prepareChannel($connection, $queue); + + $this->setupConsume($channel, $queue); + + $count = 0; + + while ($channel->is_consuming()) { + try { + $channel->wait(timeout: self::ITERATION_TIMEOUT); + } catch (AMQPTimeoutException) { + if ($this->toStop($params, $count)) { + break; + } + + continue; + } + + $count ++; + + if ($this->toStop($params, $count)) { + break; + } + } + + $channel->close(); + $this->closeConnection($connection); + + $this->stopped = false; + } + + public function stop(): void + { + $this->stopped = true; + } + + private function prepareChannel(AMQPStreamConnection $connection, string $queue): AMQPChannel + { + $channel = $connection->channel(); + + $channel->queue_declare( + queue: $queue, + durable: true, + auto_delete: false, + ); + + $channel->basic_qos( + prefetch_size: 0, + prefetch_count: 1, + a_global: false, + ); + + return $channel; + } + + private function closeConnection(AMQPStreamConnection $connection): void + { + try { + $connection->close(); + } catch (Exception $e) { + throw new RuntimeException("Connection closing error.", previous: $e); + } + } + + private function getJobId(AMQPMessage $message): string + { + $payload = json_decode($message->getBody()); + + if (!is_object($payload)) { + throw new RuntimeException("Bad payload."); + } + + $id = $payload->id ?? null; + + if (!is_string($id)) { + throw new RuntimeException("No string ID."); + } + + return $id; + } + + private function nack(AMQPMessage $message): void + { + $message->nack(false, true); + } + + private function setupConsume(AMQPChannel $channel, string $queue): void + { + $channel->basic_consume( + queue: $queue, + callback: function (AMQPMessage $message) { + try { + $id = $this->getJobId($message); + } catch (Throwable $e) { + $this->log->error("Worker: Could not get job ID.", ['exception' => $e]); + + $this->nack($message); + + return; + } + + try { + $job = $this->jobProvider->get($id); + + $this->jobRunner->run($job); + } catch (Throwable $e) { + $this->log->error("Worker: Job {id} failed.", [ + 'exception' => $e, + 'id' => $id, + ]); + + $this->nack($message); + + return; + } + + $message->ack(); + }, + ); + } + + private function toStop(Params $params, int $count): bool + { + return + $this->stopped || + $params->limit && $count >= $params->limit || + $this->exitPolicy->toExit(); + } +} diff --git a/application/Espo/Core/Job/Processing/RabbitMq/Publisher.php b/application/Espo/Core/Job/Processing/RabbitMq/Publisher.php new file mode 100644 index 00000000000..789b78cca83 --- /dev/null +++ b/application/Espo/Core/Job/Processing/RabbitMq/Publisher.php @@ -0,0 +1,117 @@ +. + * + * 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\Job\Processing\RabbitMq; + +use Espo\Core\Job\Processing\Publisher as PublisherInterface; +use Espo\Core\Job\Processing\Publisher\Params; +use Espo\Core\Utils\Json; +use Espo\Entities\Job; +use Espo\ORM\Name\Attribute; +use Exception; +use PhpAmqpLib\Channel\AMQPChannel; +use PhpAmqpLib\Connection\AMQPStreamConnection; +use PhpAmqpLib\Message\AMQPMessage; +use RuntimeException; + +class Publisher implements PublisherInterface +{ + public const string QUEUE = 'espo.jobs'; + + private ?AMQPStreamConnection $connection = null; + private ?AMQPChannel $channel = null; + private string $queue = self::QUEUE; + + public function __construct( + private ConnectionFactory $connectionFactory, + ) {} + + public function initialize(Params $params): void + { + $this->queue = Util::composeQueueName($params->queue); + + $this->connection = $this->connectionFactory->create(); + $this->channel = $this->prepareChannel($this->connection); + } + + public function publish(Job $job): void + { + if (!$this->channel) { + throw new RuntimeException("No channel."); + } + + $message = $this->prepareMessage($job); + + $this->channel->basic_publish( + msg: $message, + routing_key: $this->queue, + ); + } + + public function close(): void + { + $this->channel?->close(); + + if ($this->connection) { + $this->closeConnection($this->connection); + } + + $this->channel = null; + $this->connection = null; + } + + private function prepareChannel(AMQPStreamConnection $connection): AMQPChannel + { + $channel = $connection->channel(); + + $channel->queue_declare( + queue: $this->queue, + durable: true, + auto_delete: false, + ); + + return $channel; + } + + private function closeConnection(AMQPStreamConnection $connection): void + { + try { + $connection->close(); + } catch (Exception $e) { + throw new RuntimeException("Connection closing error.", previous: $e); + } + } + + private function prepareMessage(Job $job): AMQPMessage + { + $payload = Json::encode([Attribute::ID => $job->getId()]); + + return new AMQPMessage($payload, ['delivery_mode' => AMQPMessage::DELIVERY_MODE_PERSISTENT]); + } +} diff --git a/application/Espo/Core/Job/Processing/RabbitMq/Util.php b/application/Espo/Core/Job/Processing/RabbitMq/Util.php new file mode 100644 index 00000000000..b54df686fae --- /dev/null +++ b/application/Espo/Core/Job/Processing/RabbitMq/Util.php @@ -0,0 +1,44 @@ +. + * + * 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\Job\Processing\RabbitMq; + +class Util +{ + public static function composeQueueName(?string $queue): string + { + $name = Publisher::QUEUE; + + if ($queue !== null) { + $name .= '.' . $queue; + } + + return $name; + } +} diff --git a/application/Espo/Core/Job/Processing/Util/ExitPolicy.php b/application/Espo/Core/Job/Processing/Util/ExitPolicy.php new file mode 100644 index 00000000000..b656ad153a0 --- /dev/null +++ b/application/Espo/Core/Job/Processing/Util/ExitPolicy.php @@ -0,0 +1,50 @@ +. + * + * 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\Job\Processing\Util; + +use Espo\Core\Utils\Config; +use Espo\Core\Utils\Config\StateConfig; + +class ExitPolicy +{ + private int $cacheTimestamp; + + public function __construct( + private StateConfig $stateConfig, + private Config\StateConfigDirect $stateConfigDirect, + ) { + $this->cacheTimestamp = $this->stateConfig->getCacheTimestamp(); + } + + public function toExit(): bool + { + return $this->cacheTimestamp !== $this->stateConfigDirect->getCacheTimestamp(); + } +} diff --git a/application/Espo/Core/Job/Processing/Util/ExitSetup.php b/application/Espo/Core/Job/Processing/Util/ExitSetup.php new file mode 100644 index 00000000000..5063d354f1a --- /dev/null +++ b/application/Espo/Core/Job/Processing/Util/ExitSetup.php @@ -0,0 +1,46 @@ +. + * + * 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\Job\Processing\Util; + +use Closure; + +class ExitSetup +{ + public function setup(Closure $handler): void + { + if (!extension_loaded('pcntl')) { + return; + } + + pcntl_async_signals(true); + pcntl_signal(SIGTERM, fn () => $handler()); + pcntl_signal(SIGINT, fn () => $handler()); + } +} diff --git a/application/Espo/Core/Job/Processing/WorkerDaemon.php b/application/Espo/Core/Job/Processing/WorkerDaemon.php new file mode 100644 index 00000000000..fb3ee3b0d45 --- /dev/null +++ b/application/Espo/Core/Job/Processing/WorkerDaemon.php @@ -0,0 +1,67 @@ +. + * + * 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\Job\Processing; + +use Espo\Core\Job\Processing\Consumer\Params; +use Espo\Core\Job\Processing\Util\ExitSetup; + +/** + * @since 10.1.0 + * @internal + */ +class WorkerDaemon +{ + public function __construct( + private Consumer $consumer, + private ExitSetup $exitSetup, + ) {} + + public function run(WorkerDaemon\Params $params): void + { + $consumerParams = $this->prepareParams($params); + + $this->setupExit(); + + $this->consumer->start($consumerParams); + } + + private function setupExit(): void + { + $this->exitSetup->setup(fn () => $this->consumer->stop()); + } + + private function prepareParams(WorkerDaemon\Params $params): Params + { + return new Params( + limit: $params->limit, + queue: $params->queue, + ); + } +} diff --git a/application/Espo/Core/Job/Processing/WorkerDaemon/Params.php b/application/Espo/Core/Job/Processing/WorkerDaemon/Params.php new file mode 100644 index 00000000000..6a06a30f914 --- /dev/null +++ b/application/Espo/Core/Job/Processing/WorkerDaemon/Params.php @@ -0,0 +1,38 @@ +. + * + * 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\Job\Processing\WorkerDaemon; + +readonly class Params +{ + public function __construct( + public ?int $limit = null, + public ?string $queue = null, + ) {} +} diff --git a/application/Espo/Core/Job/QueueName.php b/application/Espo/Core/Job/QueueName.php index ca8bc4371b4..ec2b36f4e38 100644 --- a/application/Espo/Core/Job/QueueName.php +++ b/application/Espo/Core/Job/QueueName.php @@ -32,19 +32,19 @@ class QueueName { /** - * Executes as soon as possible. Non-parallel. + * Executes as soon as possible. Non-parallel by default. */ - public const Q0 = 'q0'; + public const string Q0 = 'q0'; /** - * Executes every minute. Non-parallel. + * Executes every minute. Non-parallel by default. */ - public const Q1 = 'q1'; + public const string Q1 = 'q1'; /** - * Executes as soon as possible. For email processing. Non-parallel. + * Executes as soon as possible. For email processing. Non-parallel by default. */ - public const E0 = 'e0'; + public const string E0 = 'e0'; /** * Executes in the main queue pool in parallel. Along with jobs without specified queue. @@ -54,5 +54,5 @@ class QueueName * * @since 9.2.0 */ - const M0 = 'm0'; + const string M0 = 'm0'; } diff --git a/application/Espo/Core/Job/QueueProcessor/QueueProcessors/ProcessPoolQueueProcessor.php b/application/Espo/Core/Job/QueueProcessor/QueueProcessors/ProcessPoolQueueProcessor.php index 0de8b7454f3..0579833688f 100644 --- a/application/Espo/Core/Job/QueueProcessor/QueueProcessors/ProcessPoolQueueProcessor.php +++ b/application/Espo/Core/Job/QueueProcessor/QueueProcessors/ProcessPoolQueueProcessor.php @@ -108,6 +108,7 @@ private function toSkip(bool $noLock, Job $job): bool private function prepareJob(Job $job): void { $job + // Needed for failing not started. ->setStartedAtNow() ->setStatus(Status::READY); diff --git a/application/Espo/Core/Job/QueueUtil.php b/application/Espo/Core/Job/QueueUtil.php index 0e781e4040b..458565f767f 100644 --- a/application/Espo/Core/Job/QueueUtil.php +++ b/application/Espo/Core/Job/QueueUtil.php @@ -46,7 +46,7 @@ class QueueUtil { private const int NOT_EXISTING_PROCESS_PERIOD = 300; - private const int READY_NOT_STARTED_PERIOD = 60; + private const int READY_NOT_STARTED_PERIOD = 600; public function __construct( private Config $config, @@ -476,7 +476,7 @@ public function updateFailedJobAttempts(): void ]) ->find(); - foreach ($jobCollection as $job) { + foreach ($jobCollection as $job) { $failedAttempts = $job->getFailedAttempts(); $attempts = $job->getAttempts(); diff --git a/application/Espo/Core/Job/ScheduleProcessor.php b/application/Espo/Core/Job/ScheduleProcessor.php index 403bd2ca7fc..e8e79cc6654 100644 --- a/application/Espo/Core/Job/ScheduleProcessor.php +++ b/application/Espo/Core/Job/ScheduleProcessor.php @@ -31,6 +31,7 @@ use DateTimeZone; use Espo\Core\Job\Preparator\Data as PreparatorData; +use Espo\Core\Job\ScheduleProcessor\Params; use Espo\Core\ORM\EntityManager; use Espo\Core\Utils\DateTime as DateTimeUtil; use Espo\Core\Utils\Log; @@ -66,15 +67,15 @@ public function __construct( private ScheduleUtil $scheduleUtil, private PreparatorFactory $preparatorFactory, private MetadataProvider $metadataProvider, - private ConfigDataProvider $configDataProvider + private ConfigDataProvider $configDataProvider, ) {} - public function process(): void + public function process(Params $params = new Params()): void { - $activeScheduledJobList = $this->scheduleUtil->getActiveScheduledJobList(); + $activeScheduledJobs = $this->scheduleUtil->getActiveScheduledJobs($params); $runningScheduledJobIdList = $this->queueUtil->getRunningScheduledJobIdList(); - foreach ($activeScheduledJobList as $scheduledJob) { + foreach ($activeScheduledJobs as $scheduledJob) { try { $isRunning = in_array($scheduledJob->getId(), $runningScheduledJobIdList); diff --git a/application/Espo/Core/Job/ScheduleProcessor/Params.php b/application/Espo/Core/Job/ScheduleProcessor/Params.php new file mode 100644 index 00000000000..7fcc0914905 --- /dev/null +++ b/application/Espo/Core/Job/ScheduleProcessor/Params.php @@ -0,0 +1,40 @@ +. + * + * 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\Job\ScheduleProcessor; + +/** + * @since 10.1.0 + */ +readonly class Params +{ + public function __construct( + public bool $skipQueues = false, + ) {} +} diff --git a/application/Espo/Core/Job/ScheduleUtil.php b/application/Espo/Core/Job/ScheduleUtil.php index 7413fdb5335..71ee7632ea3 100644 --- a/application/Espo/Core/Job/ScheduleUtil.php +++ b/application/Espo/Core/Job/ScheduleUtil.php @@ -29,14 +29,22 @@ namespace Espo\Core\Job; +use Espo\Core\Job\Job\Jobs\ProcessJobQueueE0; +use Espo\Core\Job\Job\Jobs\ProcessJobQueueQ0; +use Espo\Core\Job\Job\Jobs\ProcessJobQueueQ1; +use Espo\Core\Job\ScheduleProcessor\Params; +use Espo\Core\Name\Field; use Espo\Core\Utils\DateTime as DateTimeUtil; use Espo\Core\ORM\Repository\Option\SaveOption; use Espo\ORM\Collection; use Espo\ORM\EntityManager; -use Espo\Entities\ScheduledJob as ScheduledJobEntity; +use Espo\Entities\ScheduledJob; use Espo\Entities\ScheduledJobLogRecord as ScheduledJobLogRecordEntity; use Espo\ORM\Name\Attribute; +/** + * @internal + */ class ScheduleUtil { public function __construct(private EntityManager $entityManager) @@ -45,24 +53,33 @@ public function __construct(private EntityManager $entityManager) /** * Get active scheduled job list. * - * @return Collection + * @return Collection */ - public function getActiveScheduledJobList(): Collection + public function getActiveScheduledJobs(Params $params): Collection { - /** @var Collection $collection */ $collection = $this->entityManager - ->getRDBRepository(ScheduledJobEntity::ENTITY_TYPE) + ->getRDBRepositoryByClass(ScheduledJob::class) ->select([ Attribute::ID, - 'scheduling', - 'job', - 'name', + ScheduledJob::FIELD_SCHEDULING, + ScheduledJob::FIELD_JOB, + Field::NAME, ]) ->where([ - 'status' => ScheduledJobEntity::STATUS_ACTIVE, + ScheduledJob::FIELD_STATUS => ScheduledJob::STATUS_ACTIVE, ]) ->find(); + if ($params->skipQueues) { + $collection = $collection->filter(function (ScheduledJob $entity) { + return !in_array($entity->getJob(), [ + ProcessJobQueueE0::NAME, + ProcessJobQueueQ0::NAME, + ProcessJobQueueQ1::NAME, + ]); + }); + } + return $collection; } @@ -81,8 +98,8 @@ public function addLogRecord( $runTime = date(DateTimeUtil::SYSTEM_DATE_TIME_FORMAT); } - /** @var ScheduledJobEntity|null $scheduledJob */ - $scheduledJob = $this->entityManager->getEntityById(ScheduledJobEntity::ENTITY_TYPE, $scheduledJobId); + /** @var ScheduledJob|null $scheduledJob */ + $scheduledJob = $this->entityManager->getEntityById(ScheduledJob::ENTITY_TYPE, $scheduledJobId); if (!$scheduledJob) { return; @@ -94,7 +111,7 @@ public function addLogRecord( $scheduledJobLog = $this->entityManager->getNewEntity(ScheduledJobLogRecordEntity::ENTITY_TYPE); - $scheduledJobLog->set([ + $scheduledJobLog->setMultiple([ 'scheduledJobId' => $scheduledJobId, 'name' => $scheduledJob->getName(), 'status' => $status, diff --git a/application/Espo/Core/Utils/Config/StateConfig.php b/application/Espo/Core/Utils/Config/StateConfig.php new file mode 100644 index 00000000000..373b56ab9f9 --- /dev/null +++ b/application/Espo/Core/Utils/Config/StateConfig.php @@ -0,0 +1,47 @@ +. + * + * 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 10.1.0 + */ +class StateConfig +{ + public function __construct( + private Config $config, + ) {} + + public function getCacheTimestamp(): int + { + return $this->config->get('cacheTimestamp') ?? 0; + } +} diff --git a/application/Espo/Core/Utils/Config/StateConfigDirect.php b/application/Espo/Core/Utils/Config/StateConfigDirect.php new file mode 100644 index 00000000000..b67f51cf1ba --- /dev/null +++ b/application/Espo/Core/Utils/Config/StateConfigDirect.php @@ -0,0 +1,52 @@ +. + * + * 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; + +/** + * @internal + * @since 10.1.0 + */ +class StateConfigDirect +{ + private string $stateConfigPath = 'data/state.php'; + + private const string CACHE_TIMESTAMP_KEY = 'cacheTimestamp'; + + public function __construct( + private ConfigFileManager $configFileManager, + ) {} + + public function getCacheTimestamp(): int + { + $data = $this->configFileManager->getPhpContents($this->stateConfigPath); + + return $data[self::CACHE_TIMESTAMP_KEY] ?? 0; + } +} diff --git a/application/Espo/Entities/ScheduledJob.php b/application/Espo/Entities/ScheduledJob.php index ffd55c1d9e0..d2d223df08b 100644 --- a/application/Espo/Entities/ScheduledJob.php +++ b/application/Espo/Entities/ScheduledJob.php @@ -38,6 +38,15 @@ class ScheduledJob extends Entity public const string STATUS_ACTIVE = 'Active'; + /** + * @since 10.1.0 + */ + public const string FIELD_SCHEDULING = 'scheduling'; + /** + * @since 10.1.0 + */ + public const string FIELD_STATUS = 'status'; + /** * @since 10.0.0 */ @@ -50,7 +59,7 @@ public function getName(): ?string public function getScheduling(): ?string { - return $this->get('scheduling'); + return $this->get(self::FIELD_SCHEDULING); } public function getJob(): ?string @@ -63,7 +72,7 @@ public function getJob(): ?string */ public function setActive(): self { - return $this->set('status', self::STATUS_ACTIVE); + return $this->set(self::FIELD_STATUS, self::STATUS_ACTIVE); } /** @@ -79,7 +88,7 @@ public function setName(string $name): self */ public function setScheduling(string $scheduling): self { - return $this->set('scheduling', $scheduling); + return $this->set(self::FIELD_SCHEDULING, $scheduling); } /** diff --git a/application/Espo/Resources/defaults/systemConfig.php b/application/Espo/Resources/defaults/systemConfig.php index e93d8be953f..348b336bcc6 100644 --- a/application/Espo/Resources/defaults/systemConfig.php +++ b/application/Espo/Resources/defaults/systemConfig.php @@ -120,6 +120,7 @@ 'passwordRecoveryInternalIntervalPeriod', 'cleanupAppLog', 'cleanupAppLogPeriod', + 'rabbitMq', ], 'adminItems' => [ 'devMode', diff --git a/application/Espo/Resources/metadata/app/consoleCommands.json b/application/Espo/Resources/metadata/app/consoleCommands.json index e89648a3856..2558ed1e6e8 100644 --- a/application/Espo/Resources/metadata/app/consoleCommands.json +++ b/application/Espo/Resources/metadata/app/consoleCommands.json @@ -106,5 +106,36 @@ "migrationVersionStep": { "listed": false, "noSystemUser": true + }, + "job:prepare": { + "className": "Espo\\Classes\\ConsoleCommands\\JobPrepare", + "listed": false, + "allowedFlags": [ + "sq" + ], + "allowedOptions": [ + "interval", + "limit" + ] + }, + "job:enqueue": { + "className": "Espo\\Classes\\ConsoleCommands\\JobEnqueue", + "listed": false, + "allowedFlags": [], + "allowedOptions": [ + "interval", + "limit", + "portion", + "queue" + ] + }, + "job:worker": { + "className": "Espo\\Classes\\ConsoleCommands\\JobWorker", + "listed": false, + "allowedFlags": [], + "allowedOptions": [ + "limit", + "queue" + ] } } diff --git a/composer.json b/composer.json index 8d0cc768326..dce4186af13 100644 --- a/composer.json +++ b/composer.json @@ -56,7 +56,8 @@ "directorytree/imapengine": "^1.19", "zbateson/mail-mime-parser": "^3.0", "guzzlehttp/guzzle": "^7.10", - "devtheorem/php-handlebars": "^1.0" + "devtheorem/php-handlebars": "^1.0", + "php-amqplib/php-amqplib": "^3.7" }, "require-dev": { "phpunit/phpunit": "^11.5", diff --git a/composer.lock b/composer.lock index 4ad373ad7e3..7a18c8f8e6a 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "c4971184fe7b94063ee84e91227012bc", + "content-hash": "e8dd3b2f9887137751d0a383c4e27ce5", "packages": [ { "name": "async-aws/core", @@ -3990,6 +3990,87 @@ }, "time": "2020-10-15T08:29:30+00:00" }, + { + "name": "php-amqplib/php-amqplib", + "version": "v3.7.4", + "source": { + "type": "git", + "url": "https://github.com/php-amqplib/php-amqplib.git", + "reference": "381b6f7c600e0e0c7463cdd7f7a1a3bc6268e5fd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-amqplib/php-amqplib/zipball/381b6f7c600e0e0c7463cdd7f7a1a3bc6268e5fd", + "reference": "381b6f7c600e0e0c7463cdd7f7a1a3bc6268e5fd", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "ext-sockets": "*", + "php": "^7.2||^8.0", + "phpseclib/phpseclib": "^2.0|^3.0" + }, + "conflict": { + "php": "7.4.0 - 7.4.1" + }, + "replace": { + "videlalvaro/php-amqplib": "self.version" + }, + "require-dev": { + "ext-curl": "*", + "nategood/httpful": "^0.2.20", + "phpunit/phpunit": "^7.5|^9.5", + "squizlabs/php_codesniffer": "^3.6" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "psr-4": { + "PhpAmqpLib\\": "PhpAmqpLib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1-or-later" + ], + "authors": [ + { + "name": "Alvaro Videla", + "role": "Original Maintainer" + }, + { + "name": "Raúl Araya", + "email": "nubeiro@gmail.com", + "role": "Maintainer" + }, + { + "name": "Luke Bakken", + "email": "luke@bakken.io", + "role": "Maintainer" + }, + { + "name": "Ramūnas Dronga", + "email": "github@ramuno.lt", + "role": "Maintainer" + } + ], + "description": "Formerly videlalvaro/php-amqplib. This library is a pure PHP implementation of the AMQP protocol. It's been tested against RabbitMQ.", + "homepage": "https://github.com/php-amqplib/php-amqplib/", + "keywords": [ + "message", + "queue", + "rabbitmq" + ], + "support": { + "issues": "https://github.com/php-amqplib/php-amqplib/issues", + "source": "https://github.com/php-amqplib/php-amqplib/tree/v3.7.4" + }, + "time": "2025-11-23T17:00:56+00:00" + }, { "name": "php-di/invoker", "version": "2.3.7", diff --git a/tests/integration/Espo/Core/Job/JobTest.php b/tests/integration/Espo/Core/Job/JobTest.php index e320b55fc35..a0f79f66934 100644 --- a/tests/integration/Espo/Core/Job/JobTest.php +++ b/tests/integration/Espo/Core/Job/JobTest.php @@ -33,6 +33,7 @@ use Espo\Core\Job\Job\Status; use Espo\Core\Job\JobManager; use Espo\Core\Job\JobSchedulerFactory; +use Espo\Core\Job\PrepareProcessor; use Espo\Core\Job\QueueName; use Espo\Entities\Job as JobEntity; @@ -56,11 +57,14 @@ class JobTest extends BaseTestCase */ private $schedulerFactory; + private ?PrepareProcessor $prepareProcessor = null; + protected function setUp(): void { parent::setUp(); - $this->jobManager = $this->getContainer()->get('jobManager'); + $this->jobManager = $this->getContainer()->getByClass(JobManager::class); + $this->prepareProcessor = $this->getInjectableFactory()->create(PrepareProcessor::class); $this->entityManager = $this->getContainer()->getByClass(EntityManager::class); @@ -124,7 +128,7 @@ public function testProcessQueueGroupAll(): void ]); - $this->jobManager->prepare(); + $this->prepareProcessor->process(); $this->jobManager->processMainQueue(); $job1Reloaded = $this->entityManager->getEntityById('Job', $job1->getId()); From 2fbad4b1ec9ea4dc5f16223281da5c89fbaee2a1 Mon Sep 17 00:00:00 2001 From: Yurii Date: Fri, 10 Jul 2026 17:06:18 +0300 Subject: [PATCH 04/87] Job PID on layout --- .../Espo/Resources/i18n/en_US/Job.json | 3 ++- .../Espo/Resources/layouts/Job/detail.json | 22 +++++++++---------- .../Resources/layouts/Job/detailSmall.json | 22 +++++++++---------- .../Espo/Resources/layouts/Job/filters.json | 3 ++- .../Resources/metadata/entityDefs/Job.json | 3 ++- 5 files changed, 28 insertions(+), 25 deletions(-) diff --git a/application/Espo/Resources/i18n/en_US/Job.json b/application/Espo/Resources/i18n/en_US/Job.json index b874985d834..4227e67424b 100644 --- a/application/Espo/Resources/i18n/en_US/Job.json +++ b/application/Espo/Resources/i18n/en_US/Job.json @@ -16,7 +16,8 @@ "group": "Group", "className": "Class Name", "targetGroup": "Target Group", - "job": "Job" + "job": "Job", + "pid": "PID" }, "options": { "status": { diff --git a/application/Espo/Resources/layouts/Job/detail.json b/application/Espo/Resources/layouts/Job/detail.json index 0d603e97904..eaa71cd3b8b 100644 --- a/application/Espo/Resources/layouts/Job/detail.json +++ b/application/Espo/Resources/layouts/Job/detail.json @@ -1,25 +1,25 @@ [ { "rows": [ - [{"name":"name"}, {"name": "status"}], - [{"name":"queue"}, {"name":"number"}], - [{"name":"group"}, false] + [{"name": "name"}, {"name": "status"}], + [{"name": "queue"}, {"name":"number"}], + [{"name": "group"}, false] ] }, { "rows": [ - [{"name":"executeTime"}, {"name": "createdAt"}], - [{"name":"startedAt"}, {"name": "modifiedAt"}], - [{"name":"executedAt"}, false], - [{"name":"attempts"}, false], - [{"name":"failedAttempts"}, false] + [{"name": "executeTime"}, {"name": "createdAt"}], + [{"name": "startedAt"}, {"name": "modifiedAt"}], + [{"name": "executedAt"}, false], + [{"name": "attempts"}, {"name": "pid"}], + [{"name": "failedAttempts"}, false] ] }, { "rows": [ - [{"name":"scheduledJob"}, {"name":"targetType"}], - [{"name":"className"}, {"name":"targetId"}], - [{"name":"job"}, false], + [{"name": "scheduledJob"}, {"name": "targetType"}], + [{"name": "className"}, {"name": "targetId"}], + [{"name": "job"}, false], [{"name": "data"}] ] } diff --git a/application/Espo/Resources/layouts/Job/detailSmall.json b/application/Espo/Resources/layouts/Job/detailSmall.json index 0d603e97904..3170d1c8f34 100644 --- a/application/Espo/Resources/layouts/Job/detailSmall.json +++ b/application/Espo/Resources/layouts/Job/detailSmall.json @@ -1,25 +1,25 @@ [ { "rows": [ - [{"name":"name"}, {"name": "status"}], - [{"name":"queue"}, {"name":"number"}], - [{"name":"group"}, false] + [{"name": "name"}, {"name": "status"}], + [{"name": "queue"}, {"name": "number"}], + [{"name": "group"}, false] ] }, { "rows": [ - [{"name":"executeTime"}, {"name": "createdAt"}], - [{"name":"startedAt"}, {"name": "modifiedAt"}], - [{"name":"executedAt"}, false], - [{"name":"attempts"}, false], - [{"name":"failedAttempts"}, false] + [{"name": "executeTime"}, {"name": "createdAt"}], + [{"name": "startedAt"}, {"name": "modifiedAt"}], + [{"name": "executedAt"}, false], + [{"name": "attempts"}, {"name": "pid"}], + [{"name": "failedAttempts"}, false] ] }, { "rows": [ - [{"name":"scheduledJob"}, {"name":"targetType"}], - [{"name":"className"}, {"name":"targetId"}], - [{"name":"job"}, false], + [{"name": "scheduledJob"}, {"name": "targetType"}], + [{"name": "className"}, {"name": "targetId"}], + [{"name": "job"}, false], [{"name": "data"}] ] } diff --git a/application/Espo/Resources/layouts/Job/filters.json b/application/Espo/Resources/layouts/Job/filters.json index b8f873d0163..aa7226d5a80 100644 --- a/application/Espo/Resources/layouts/Job/filters.json +++ b/application/Espo/Resources/layouts/Job/filters.json @@ -6,5 +6,6 @@ "executedAt", "queue", "group", - "className" + "className", + "pid" ] diff --git a/application/Espo/Resources/metadata/entityDefs/Job.json b/application/Espo/Resources/metadata/entityDefs/Job.json index 410c09448b7..e06a79d30ca 100644 --- a/application/Espo/Resources/metadata/entityDefs/Job.json +++ b/application/Espo/Resources/metadata/entityDefs/Job.json @@ -75,7 +75,8 @@ "hasSeconds": true }, "pid": { - "type": "int" + "type": "int", + "disableFormatting": true }, "attempts": { "type": "int" From 11b4794df0be69f0fe1c2611d6405a4bda54428a Mon Sep 17 00:00:00 2001 From: Yurii Date: Sat, 11 Jul 2026 12:52:37 +0300 Subject: [PATCH 05/87] RDBRepository type inferring collection type --- .../Espo/ORM/Repository/RDBRepository.php | 34 +++++++++---------- .../Espo/ORM/Repository/RDBSelectBuilder.php | 31 +++++++++-------- 2 files changed, 33 insertions(+), 32 deletions(-) diff --git a/application/Espo/ORM/Repository/RDBRepository.php b/application/Espo/ORM/Repository/RDBRepository.php index b19ae2a4841..d69aab17aaa 100644 --- a/application/Espo/ORM/Repository/RDBRepository.php +++ b/application/Espo/ORM/Repository/RDBRepository.php @@ -293,9 +293,9 @@ private function removeInternal(Entity $entity, array $options = []): void /** * Find records. * - * @return EntityCollection|SthCollection + * @return EntityCollection */ - public function find(): EntityCollection|SthCollection + public function find(): EntityCollection { return $this->createBuilder()->find(); } @@ -372,7 +372,7 @@ public function sum(string $attribute) /** * Clone an existing query for a further modification and usage by 'find' or 'count' methods. * - * @return RDBSelectBuilder + * @return RDBSelectBuilder> */ public function clone(Select $query): RDBSelectBuilder { @@ -380,7 +380,7 @@ public function clone(Select $query): RDBSelectBuilder throw new RuntimeException("Can't clone a query of a different entity type."); } - /** @var RDBSelectBuilder $builder */ + /** @var RDBSelectBuilder> $builder */ $builder = new RDBSelectBuilder($this->entityManager, $this->entityType, $query); return $builder; @@ -393,7 +393,7 @@ public function clone(Select $query): RDBSelectBuilder * A relation name or table. A relation name should be in camelCase, a table in CamelCase. * @param string|null $alias An alias. * @param WhereItem|array|null $conditions Join conditions. - * @return RDBSelectBuilder + * @return RDBSelectBuilder> */ public function join($target, ?string $alias = null, $conditions = null): RDBSelectBuilder { @@ -407,7 +407,7 @@ public function join($target, ?string $alias = null, $conditions = null): RDBSel * A relation name or table. A relation name should be in camelCase, a table in CamelCase. * @param string|null $alias An alias. * @param WhereItem|array|null $conditions Join conditions. - * @return RDBSelectBuilder + * @return RDBSelectBuilder> */ public function leftJoin($target, ?string $alias = null, $conditions = null): RDBSelectBuilder { @@ -417,7 +417,7 @@ public function leftJoin($target, ?string $alias = null, $conditions = null): RD /** * Set DISTINCT parameter. * - * @return RDBSelectBuilder + * @return RDBSelectBuilder> */ public function distinct(): RDBSelectBuilder { @@ -427,7 +427,7 @@ public function distinct(): RDBSelectBuilder /** * Lock selected rows. To be used within a transaction. * - * @return RDBSelectBuilder + * @return RDBSelectBuilder> */ public function forUpdate(): RDBSelectBuilder { @@ -437,7 +437,7 @@ public function forUpdate(): RDBSelectBuilder /** * Set to return STH collection. Recommended fetching large number of records. * - * @return RDBSelectBuilder + * @return RDBSelectBuilder> */ public function sth(): RDBSelectBuilder { @@ -454,7 +454,7 @@ public function sth(): RDBSelectBuilder * * @param WhereItem|array|string $clause A key or where clause. * @param mixed[]|scalar|null $value A value. Should be omitted if the first argument is not string. - * @return RDBSelectBuilder + * @return RDBSelectBuilder> */ public function where($clause = [], $value = null): RDBSelectBuilder { @@ -471,7 +471,7 @@ public function where($clause = [], $value = null): RDBSelectBuilder * * @param WhereItem|array|string $clause A key or where clause. * @param mixed[]|scalar|null $value A value. Should be omitted if the first argument is not string. - * @return RDBSelectBuilder + * @return RDBSelectBuilder> */ public function having($clause = [], $value = null): RDBSelectBuilder { @@ -491,7 +491,7 @@ public function having($clause = [], $value = null): RDBSelectBuilder * An attribute to order by or an array or order items. * Passing an array will reset a previously set order. * @param (Order::ASC|Order::DESC)|bool|null $direction A direction. - * @return RDBSelectBuilder + * @return RDBSelectBuilder> */ public function order($orderBy = Attribute::ID, $direction = null): RDBSelectBuilder { @@ -501,7 +501,7 @@ public function order($orderBy = Attribute::ID, $direction = null): RDBSelectBui /** * Apply OFFSET and LIMIT. * - * @return RDBSelectBuilder + * @return RDBSelectBuilder> */ public function limit(?int $offset = null, ?int $limit = null): RDBSelectBuilder { @@ -521,7 +521,7 @@ public function limit(?int $offset = null, ?int $limit = null): RDBSelectBuilder * @param Selection|Selection[]|Expression|Expression[]|string[]|string|array $select * An array of expressions or one expression. * @param string|null $alias An alias. Actual if the first parameter is not an array. - * @return RDBSelectBuilder + * @return RDBSelectBuilder> */ public function select($select = [], ?string $alias = null): RDBSelectBuilder { @@ -538,7 +538,7 @@ public function select($select = [], ?string $alias = null): RDBSelectBuilder * * `groupBy([$expr1, $expr2, ...])` * * @param Expression|Expression[]|string|string[] $groupBy - * @return RDBSelectBuilder + * @return RDBSelectBuilder> */ public function group($groupBy): RDBSelectBuilder { @@ -548,13 +548,13 @@ public function group($groupBy): RDBSelectBuilder /** * Create a select builder. * - * @return RDBSelectBuilder + * @return RDBSelectBuilder> * * @since 9.2.5 */ public function createBuilder(): RDBSelectBuilder { - /** @var RDBSelectBuilder $builder */ + /** @var RDBSelectBuilder> $builder */ $builder = new RDBSelectBuilder($this->entityManager, $this->entityType); return $builder; diff --git a/application/Espo/ORM/Repository/RDBSelectBuilder.php b/application/Espo/ORM/Repository/RDBSelectBuilder.php index d2e8db4679f..30d1f41ba79 100644 --- a/application/Espo/ORM/Repository/RDBSelectBuilder.php +++ b/application/Espo/ORM/Repository/RDBSelectBuilder.php @@ -52,6 +52,7 @@ * Builds select parameters for an RDB repository. Contains 'find' methods. * * @template TEntity of Entity + * @template TCollection of EntityCollection|SthCollection = EntityCollection */ class RDBSelectBuilder { @@ -93,15 +94,16 @@ protected function getMapper(): Mapper } /** - * @return EntityCollection|SthCollection + * @return TCollection */ - public function find(): EntityCollection|SthCollection + public function find(): Collection { $query = $this->builder->build(); /** @var Collection $collection */ $collection = $this->getMapper()->select($query); + /** @var TCollection */ return $this->handleReturnCollection($collection); } @@ -197,7 +199,7 @@ public function sum(string $attribute) * A relation name or table. A relation name should be in camelCase, a table in CamelCase. * @param string|null $alias An alias. * @param WhereItem|array|null $conditions Join conditions. - * @return RDBSelectBuilder + * @return RDBSelectBuilder */ public function join($target, ?string $alias = null, $conditions = null): self { @@ -214,7 +216,7 @@ public function join($target, ?string $alias = null, $conditions = null): self * @param string|null $alias An alias. * @param WhereItem|array|null $conditions Join conditions. * - * @return RDBSelectBuilder + * @return RDBSelectBuilder */ public function leftJoin($target, ?string $alias = null, $conditions = null): self { @@ -226,7 +228,7 @@ public function leftJoin($target, ?string $alias = null, $conditions = null): se /** * Set DISTINCT parameter. * - * @return RDBSelectBuilder + * @return RDBSelectBuilder */ public function distinct(): self { @@ -238,7 +240,7 @@ public function distinct(): self /** * Lock selected rows. To be used within a transaction. * - * @return RDBSelectBuilder + * @return RDBSelectBuilder */ public function forUpdate(): self { @@ -251,8 +253,7 @@ public function forUpdate(): self /** * Set to return STH collection. Recommended for fetching large number of records. * - * @todo Remove. - * @return RDBSelectBuilder + * @return RDBSelectBuilder> */ public function sth(): self { @@ -271,7 +272,7 @@ public function sth(): self * * @param WhereItem|array|string $clause A key or where clause. * @param mixed[]|scalar|null $value A value. Should be omitted if the first argument is not string. - * @return RDBSelectBuilder + * @return RDBSelectBuilder */ public function where($clause = [], $value = null): self { @@ -290,7 +291,7 @@ public function where($clause = [], $value = null): self * * @param WhereItem|array|string $clause A key or where clause. * @param mixed[]|scalar|null $value A value. Should be omitted if the first argument is not string. - * @return RDBSelectBuilder + * @return RDBSelectBuilder */ public function having($clause = [], $value = null): self { @@ -312,7 +313,7 @@ public function having($clause = [], $value = null): self * An attribute to order by or an array or order items. * Passing an array will reset a previously set order. * @param (Order::ASC|Order::DESC)|bool|null $direction A direction. - * @return RDBSelectBuilder + * @return RDBSelectBuilder */ public function order($orderBy = Attribute::ID, $direction = null): self { @@ -324,7 +325,7 @@ public function order($orderBy = Attribute::ID, $direction = null): self /** * Apply OFFSET and LIMIT. * - * @return RDBSelectBuilder + * @return RDBSelectBuilder */ public function limit(?int $offset = null, ?int $limit = null): self { @@ -349,7 +350,7 @@ public function limit(?int $offset = null, ?int $limit = null): self * @param Selection|Selection[]|Expression|Expression[]|string[]|string|array $select * An array of expressions or one expression. * @param string|null $alias An alias. Actual if the first parameter is a string. - * @return RDBSelectBuilder + * @return RDBSelectBuilder */ public function select($select, ?string $alias = null): self { @@ -368,7 +369,7 @@ public function select($select, ?string $alias = null): self * * `groupBy([$expr1, $expr2, ...])` * * @param Expression|Expression[]|string|string[] $groupBy - * @return RDBSelectBuilder + * @return RDBSelectBuilder */ public function group($groupBy): self { @@ -380,7 +381,7 @@ public function group($groupBy): self /** * @deprecated Use `group` method. * - * @return RDBSelectBuilder + * @return RDBSelectBuilder * @param Expression|Expression[]|string|string[] $groupBy */ public function groupBy($groupBy): self From 978563738cada377371ffa6b7112681dfcd7bcec Mon Sep 17 00:00:00 2001 From: Yurii Date: Sat, 11 Jul 2026 13:17:38 +0300 Subject: [PATCH 06/87] Cleanup --- application/Espo/ORM/BaseEntity.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/application/Espo/ORM/BaseEntity.php b/application/Espo/ORM/BaseEntity.php index c6655219e63..0f55457c12f 100644 --- a/application/Espo/ORM/BaseEntity.php +++ b/application/Espo/ORM/BaseEntity.php @@ -39,7 +39,6 @@ use Espo\ORM\Relation\EmptyRelations; use Espo\ORM\Relation\Relations; use Espo\ORM\Type\AttributeType; -use Espo\ORM\Type\RelationType; use Espo\ORM\Value\ValueAccessorFactory; use Espo\ORM\Value\ValueAccessor; @@ -47,7 +46,6 @@ use InvalidArgumentException; use RuntimeException; -use const E_USER_DEPRECATED; use const JSON_THROW_ON_ERROR; class BaseEntity implements Entity From 80c703af65208e03f8444b1bec68c1483f0b4a59 Mon Sep 17 00:00:00 2001 From: Yurii Date: Sat, 11 Jul 2026 13:23:15 +0300 Subject: [PATCH 07/87] Use entity::setMulltiple --- application/Espo/Core/Action/Actions/Merge/Merger.php | 2 +- application/Espo/Core/FieldProcessing/EmailAddress/Saver.php | 2 +- application/Espo/Core/FieldProcessing/PhoneNumber/Saver.php | 2 +- application/Espo/Core/Repositories/Database.php | 2 +- application/Espo/ORM/EntityCollection.php | 2 +- application/Espo/ORM/EntityManager.php | 4 ++-- application/Espo/ORM/Mapper/BaseMapper.php | 2 +- application/Espo/ORM/SthCollection.php | 2 +- application/Espo/ORM/Value/ValueAccessor.php | 2 +- application/Espo/Tools/Dashboard/Service.php | 4 ++-- application/Espo/Tools/MassUpdate/Processor.php | 2 +- 11 files changed, 13 insertions(+), 13 deletions(-) diff --git a/application/Espo/Core/Action/Actions/Merge/Merger.php b/application/Espo/Core/Action/Actions/Merge/Merger.php index 605fca61ac8..36208fe128c 100644 --- a/application/Espo/Core/Action/Actions/Merge/Merger.php +++ b/application/Espo/Core/Action/Actions/Merge/Merger.php @@ -105,7 +105,7 @@ private function processInternal(Params $params, array $sourceIdList, stdClass $ $service->filterUpdateInput($clonedData); - $entity->set($clonedData); + $entity->setMultiple($clonedData); $this->unsetNotActualAttributes($entity); diff --git a/application/Espo/Core/FieldProcessing/EmailAddress/Saver.php b/application/Espo/Core/FieldProcessing/EmailAddress/Saver.php index 33cae4b1efa..19e55cbe742 100644 --- a/application/Espo/Core/FieldProcessing/EmailAddress/Saver.php +++ b/application/Espo/Core/FieldProcessing/EmailAddress/Saver.php @@ -334,7 +334,7 @@ private function storeData(Entity $entity): void $entityEmailAddress = $this->entityManager->getNewEntity('EntityEmailAddress'); - $entityEmailAddress->set([ + $entityEmailAddress->setMultiple([ 'entityId' => $entity->getId(), 'entityType' => $entity->getEntityType(), 'emailAddressId' => $emailAddress->getId(), diff --git a/application/Espo/Core/FieldProcessing/PhoneNumber/Saver.php b/application/Espo/Core/FieldProcessing/PhoneNumber/Saver.php index 6f91d6eaeb3..1c15330343e 100644 --- a/application/Espo/Core/FieldProcessing/PhoneNumber/Saver.php +++ b/application/Espo/Core/FieldProcessing/PhoneNumber/Saver.php @@ -336,7 +336,7 @@ private function storeData(Entity $entity): void $entityPhoneNumber = $this->entityManager->getNewEntity('EntityPhoneNumber'); - $entityPhoneNumber->set([ + $entityPhoneNumber->setMultiple([ 'entityId' => $entity->getId(), 'entityType' => $entity->getEntityType(), 'phoneNumberId' => $phoneNumber->getId(), diff --git a/application/Espo/Core/Repositories/Database.php b/application/Espo/Core/Repositories/Database.php index 4b1f773549b..78aa6be5b3c 100644 --- a/application/Espo/Core/Repositories/Database.php +++ b/application/Espo/Core/Repositories/Database.php @@ -366,7 +366,7 @@ protected function beforeSave(Entity $entity, array $options = []) protected function afterSave(Entity $entity, array $options = []) { if (!empty($this->restoreData)) { - $entity->set($this->restoreData); + $entity->setMultiple($this->restoreData); $this->restoreData = null; } diff --git a/application/Espo/ORM/EntityCollection.php b/application/Espo/ORM/EntityCollection.php index bd650fa2178..b846b8c450b 100644 --- a/application/Espo/ORM/EntityCollection.php +++ b/application/Espo/ORM/EntityCollection.php @@ -252,7 +252,7 @@ protected function buildEntityFromArray(array $dataArray): Entity /** @var TEntity $entity */ $entity = $this->entityFactory->create($this->entityType); - $entity->set($dataArray); + $entity->setMultiple($dataArray); if ($this->isFetched) { $entity->setAsFetched(); diff --git a/application/Espo/ORM/EntityManager.php b/application/Espo/ORM/EntityManager.php index a3ec089acd2..fb451c6ac3e 100644 --- a/application/Espo/ORM/EntityManager.php +++ b/application/Espo/ORM/EntityManager.php @@ -303,7 +303,7 @@ public function refreshEntity(Entity $entity): void } } - $entity->set($fetchedMap); + $entity->setMultiple($fetchedMap); $entity->setAsFetched(); } @@ -316,7 +316,7 @@ public function refreshEntity(Entity $entity): void public function createEntity(string $entityType, $data = [], array $options = []): Entity { $entity = $this->getNewEntity($entityType); - $entity->set($data); + $entity->setMultiple($data); $this->saveEntity($entity, $options); return $entity; diff --git a/application/Espo/ORM/Mapper/BaseMapper.php b/application/Espo/ORM/Mapper/BaseMapper.php index 9ae9521629a..b835861de18 100644 --- a/application/Espo/ORM/Mapper/BaseMapper.php +++ b/application/Espo/ORM/Mapper/BaseMapper.php @@ -1583,7 +1583,7 @@ private function toValueMap(Entity $entity, bool $onlyStorable = true): array */ private function populateEntityFromRow(Entity $entity, $data): void { - $entity->set($data); + $entity->setMultiple($data); } /** diff --git a/application/Espo/ORM/SthCollection.php b/application/Espo/ORM/SthCollection.php index 8117dbfecc0..7e4dbb79758 100644 --- a/application/Espo/ORM/SthCollection.php +++ b/application/Espo/ORM/SthCollection.php @@ -85,7 +85,7 @@ public function getIterator(): Traversable while ($row = $this->fetchRow()) { $entity = $this->entityManager->getEntityFactory()->create($this->entityType); - $entity->set($row); + $entity->setMultiple($row); $entity->setAsFetched(); $this->prepareEntity($entity); diff --git a/application/Espo/ORM/Value/ValueAccessor.php b/application/Espo/ORM/Value/ValueAccessor.php index 840be59a6c7..37daa74d36f 100644 --- a/application/Espo/ORM/Value/ValueAccessor.php +++ b/application/Espo/ORM/Value/ValueAccessor.php @@ -66,6 +66,6 @@ public function set(string $field, ?object $value): void { $attributeValueMap = $this->extractor->extract($this->entity->getEntityType(), $field, $value); - $this->entity->set($attributeValueMap); + $this->entity->setMultiple($attributeValueMap); } } diff --git a/application/Espo/Tools/Dashboard/Service.php b/application/Espo/Tools/Dashboard/Service.php index da7d1878bad..e3fe86624f5 100644 --- a/application/Espo/Tools/Dashboard/Service.php +++ b/application/Espo/Tools/Dashboard/Service.php @@ -130,7 +130,7 @@ public function deployTemplateToTeam(string $id, string $teamId, bool $append = private function applyTemplate(Entity $preferences, DashboardTemplate $template, bool $append): void { if (!$append) { - $preferences->set([ + $preferences->setMultiple([ 'dashboardLayout' => $template->get('layout'), 'dashletsOptions' => $template->get('dashletsOptions'), ]); @@ -168,7 +168,7 @@ private function applyTemplate(Entity $preferences, DashboardTemplate $template, $dashletsOptions->$id = $item; } - $preferences->set([ + $preferences->setMultiple([ 'dashboardLayout' => $dashboardLayout, 'dashletsOptions' => $dashletsOptions, ]); diff --git a/application/Espo/Tools/MassUpdate/Processor.php b/application/Espo/Tools/MassUpdate/Processor.php index 3a8f608fe47..01724a16169 100644 --- a/application/Espo/Tools/MassUpdate/Processor.php +++ b/application/Espo/Tools/MassUpdate/Processor.php @@ -192,7 +192,7 @@ private function processEntity( return false; } - $entity->set($values); + $entity->setMultiple($values); try { $service->processValidation($entity, $values); From 702efd539de67235e25f2e48214a19cfb8c64274 Mon Sep 17 00:00:00 2001 From: Yurii Date: Sat, 11 Jul 2026 13:38:29 +0300 Subject: [PATCH 08/87] Entity null default suppress inspection --- .../LeadCapture/ExampleLoader.php | 1 - .../RecordHooks/EmailFilter/BeforeSave.php | 2 - .../RecordHooks/Webhook/BeforeSave.php | 1 - .../FieldProcessing/Link/NotJoinedLoader.php | 1 - .../Core/FieldProcessing/Relation/Saver.php | 1 - application/Espo/Core/ORM/Entity.php | 1 - application/Espo/Core/Repositories/Event.php | 2 - application/Espo/Entities/Integration.php | 7 +++- application/Espo/ORM/BaseEntity.php | 7 +++- application/Espo/ORM/Entity.php | 2 +- application/Espo/ORM/Entity/EmptyValue.php | 38 +++++++++++++++++++ application/Espo/ORM/Mapper/BaseMapper.php | 1 - application/Espo/Repositories/Email.php | 1 - .../Espo/Tools/UserSecurity/Service.php | 2 - tests/unit/Espo/ORM/EntityTest.php | 3 -- 15 files changed, 51 insertions(+), 19 deletions(-) create mode 100644 application/Espo/ORM/Entity/EmptyValue.php diff --git a/application/Espo/Classes/FieldProcessing/LeadCapture/ExampleLoader.php b/application/Espo/Classes/FieldProcessing/LeadCapture/ExampleLoader.php index 3e3a57fc342..4141a9310c3 100644 --- a/application/Espo/Classes/FieldProcessing/LeadCapture/ExampleLoader.php +++ b/application/Espo/Classes/FieldProcessing/LeadCapture/ExampleLoader.php @@ -141,7 +141,6 @@ private function processFormUrl(LeadCapture $entity): void $siteUrl = $this->getSiteUrl(); if (!$entity->hasFormEnabled() || !$formId) { - /** @noinspection PhpRedundantOptionalArgumentInspection */ $entity->set('formUrl', null); return; diff --git a/application/Espo/Classes/RecordHooks/EmailFilter/BeforeSave.php b/application/Espo/Classes/RecordHooks/EmailFilter/BeforeSave.php index baf64511142..c19df6657c9 100644 --- a/application/Espo/Classes/RecordHooks/EmailFilter/BeforeSave.php +++ b/application/Espo/Classes/RecordHooks/EmailFilter/BeforeSave.php @@ -115,12 +115,10 @@ private function controlEntityValues(EmailFilter $entity): void } if ($entity->getAction() !== EmailFilter::ACTION_MOVE_TO_FOLDER) { - /** @noinspection PhpRedundantOptionalArgumentInspection */ $entity->set('emailFolderId', null); } if ($entity->getAction() !== EmailFilter::ACTION_MOVE_TO_GROUP_FOLDER) { - /** @noinspection PhpRedundantOptionalArgumentInspection */ $entity->set('groupEmailFolderId', null); } } diff --git a/application/Espo/Classes/RecordHooks/Webhook/BeforeSave.php b/application/Espo/Classes/RecordHooks/Webhook/BeforeSave.php index c36d22cdf1a..4f85cc73564 100644 --- a/application/Espo/Classes/RecordHooks/Webhook/BeforeSave.php +++ b/application/Espo/Classes/RecordHooks/Webhook/BeforeSave.php @@ -167,7 +167,6 @@ private function processEntityEventData(Webhook $entity): void return; } - /** @noinspection PhpRedundantOptionalArgumentInspection */ $entity->set('field', null); } diff --git a/application/Espo/Core/FieldProcessing/Link/NotJoinedLoader.php b/application/Espo/Core/FieldProcessing/Link/NotJoinedLoader.php index 587db9b52b8..0905405e46d 100644 --- a/application/Espo/Core/FieldProcessing/Link/NotJoinedLoader.php +++ b/application/Espo/Core/FieldProcessing/Link/NotJoinedLoader.php @@ -87,7 +87,6 @@ private function processItem(Entity $entity, string $field): void ->findOne(); if (!$foreignEntity) { - /** @noinspection PhpRedundantOptionalArgumentInspection */ $entity->set($nameAttribute, null); return; diff --git a/application/Espo/Core/FieldProcessing/Relation/Saver.php b/application/Espo/Core/FieldProcessing/Relation/Saver.php index 5efa054164a..28409510652 100644 --- a/application/Espo/Core/FieldProcessing/Relation/Saver.php +++ b/application/Espo/Core/FieldProcessing/Relation/Saver.php @@ -190,7 +190,6 @@ private function processBelongsToHasOneItem(Entity $entity, string $name): void return; } - /** @noinspection PhpRedundantOptionalArgumentInspection */ $anotherEntity->set($idAttribute, null); $this->entityManager->saveEntity($anotherEntity, [ diff --git a/application/Espo/Core/ORM/Entity.php b/application/Espo/Core/ORM/Entity.php index f4939ff2a5d..baa3280f33a 100644 --- a/application/Espo/Core/ORM/Entity.php +++ b/application/Espo/Core/ORM/Entity.php @@ -98,7 +98,6 @@ public function loadParentNameField(string $field): void $toSetFetched = !$this->isNew() && !$this->isAttributeChanged($idAttribute); if (!$parentId || !$parentType) { - /** @noinspection PhpRedundantOptionalArgumentInspection */ $this->set($nameAttribute, null); if ($toSetFetched) { diff --git a/application/Espo/Core/Repositories/Event.php b/application/Espo/Core/Repositories/Event.php index ee8ba153ffd..ed8089eaf75 100644 --- a/application/Espo/Core/Repositories/Event.php +++ b/application/Espo/Core/Repositories/Event.php @@ -85,7 +85,6 @@ protected function beforeSave(Entity $entity, array $options = []) $entity->set(Meeting::FIELD_DATE_START, $dateStart); } else { - /** @noinspection PhpRedundantOptionalArgumentInspection */ $entity->set(Meeting::FIELD_DATE_START_DATE, null); } } @@ -107,7 +106,6 @@ protected function beforeSave(Entity $entity, array $options = []) $dateEnd = $dt->format(DateTimeUtil::SYSTEM_DATE_TIME_FORMAT); $entity->set(Meeting::FIELD_DATE_END, $dateEnd); } else { - /** @noinspection PhpRedundantOptionalArgumentInspection */ $entity->set(Meeting::FIELD_DATE_END_DATE, null); } } diff --git a/application/Espo/Entities/Integration.php b/application/Espo/Entities/Integration.php index cc7a7604438..377f3e6b97c 100644 --- a/application/Espo/Entities/Integration.php +++ b/application/Espo/Entities/Integration.php @@ -30,6 +30,7 @@ namespace Espo\Entities; use Espo\Core\ORM\Entity; +use Espo\ORM\Entity\EmptyValue; use Espo\ORM\Name\Attribute; use stdClass; @@ -86,7 +87,7 @@ public function setMultiple(array|stdClass $valueMap): static return $this->set($valueMap); } - public function set($attribute, $value = null): static + public function set($attribute, $value = new EmptyValue()): static { if (is_object($attribute)) { $attribute = get_object_vars($attribute); @@ -98,6 +99,10 @@ public function set($attribute, $value = null): static return $this; } + if ($value instanceof EmptyValue) { + $value = null; + } + $name = $attribute; if ($name === Attribute::ID) { diff --git a/application/Espo/ORM/BaseEntity.php b/application/Espo/ORM/BaseEntity.php index 0f55457c12f..8170ea38fcc 100644 --- a/application/Espo/ORM/BaseEntity.php +++ b/application/Espo/ORM/BaseEntity.php @@ -35,6 +35,7 @@ use Espo\ORM\Defs\Params\AttributeParam; use Espo\ORM\Defs\Params\EntityParam; use Espo\ORM\Defs\Params\RelationParam; +use Espo\ORM\Entity\EmptyValue; use Espo\ORM\Name\Attribute; use Espo\ORM\Relation\EmptyRelations; use Espo\ORM\Relation\Relations; @@ -162,7 +163,7 @@ public function reset(): void * @param string|stdClass|array $attribute * @param mixed $value */ - public function set($attribute, $value = null): static + public function set($attribute, $value = new EmptyValue()): static { $arg = $attribute; @@ -183,6 +184,10 @@ public function set($attribute, $value = null): static if (is_string($arg)) { $name = $arg; + if ($value instanceof EmptyValue) { + $value = null; + } + if ($name == Attribute::ID) { $this->id = $value; } diff --git a/application/Espo/ORM/Entity.php b/application/Espo/ORM/Entity.php index 0356fa143b0..31650bf20d8 100644 --- a/application/Espo/ORM/Entity.php +++ b/application/Espo/ORM/Entity.php @@ -89,7 +89,7 @@ public function reset(): void; * @param string|stdClass|array $attribute * @param mixed $value */ - public function set($attribute, $value = null): static; + public function set($attribute, $value): static; /** * Set multiple attributes. diff --git a/application/Espo/ORM/Entity/EmptyValue.php b/application/Espo/ORM/Entity/EmptyValue.php new file mode 100644 index 00000000000..2b4cd37be58 --- /dev/null +++ b/application/Espo/ORM/Entity/EmptyValue.php @@ -0,0 +1,38 @@ +. + * + * 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\ORM\Entity; + +/** + * @internal + * + * Prevents inspection firing on a null value. + */ +class EmptyValue +{} diff --git a/application/Espo/ORM/Mapper/BaseMapper.php b/application/Espo/ORM/Mapper/BaseMapper.php index b835861de18..501fa23e7a8 100644 --- a/application/Espo/ORM/Mapper/BaseMapper.php +++ b/application/Espo/ORM/Mapper/BaseMapper.php @@ -1172,7 +1172,6 @@ private function removeRelation( $where[$typeKey] = $foreignEntityType; } - /** @noinspection PhpRedundantOptionalArgumentInspection */ $entity->set($typeKey, null); $entity->setFetched($typeKey, null); } diff --git a/application/Espo/Repositories/Email.php b/application/Espo/Repositories/Email.php index 224ac5cc4a3..fc0d4019d14 100644 --- a/application/Espo/Repositories/Email.php +++ b/application/Espo/Repositories/Email.php @@ -558,7 +558,6 @@ private function processBeforeSaveFrom(EmailEntity $entity): void $from = trim($entity->getFromAddress() ?? ''); if (!$from) { - /** @noinspection PhpRedundantOptionalArgumentInspection */ $entity->set(self::ATTR_FROM_EMAIL_ADDRESS_ID, null); return; diff --git a/application/Espo/Tools/UserSecurity/Service.php b/application/Espo/Tools/UserSecurity/Service.php index c97be0a2c40..6346573dba9 100644 --- a/application/Espo/Tools/UserSecurity/Service.php +++ b/application/Espo/Tools/UserSecurity/Service.php @@ -172,7 +172,6 @@ public function getTwoFactorUserSetupData(string $id, stdClass $data): stdClass } $userData->set('auth2FA', false); - /** @noinspection PhpRedundantOptionalArgumentInspection */ $userData->set('auth2FAMethod', null); $this->entityManager->saveEntity($userData); @@ -233,7 +232,6 @@ public function update(string $id, stdClass $data): stdClass } if (!$userData->get('auth2FA')) { - /** @noinspection PhpRedundantOptionalArgumentInspection */ $userData->set('auth2FAMethod', null); } diff --git a/tests/unit/Espo/ORM/EntityTest.php b/tests/unit/Espo/ORM/EntityTest.php index 18d83bd0ba5..8e5843ecd48 100644 --- a/tests/unit/Espo/ORM/EntityTest.php +++ b/tests/unit/Espo/ORM/EntityTest.php @@ -99,7 +99,6 @@ public function testIsAttributeChanged() $this->assertTrue($job->isAttributeChanged('string')); $job = $this->createEntity('Job', Job::class); - /** @noinspection PhpRedundantOptionalArgumentInspection */ $job->set('string', null); $this->assertTrue($job->isAttributeChanged('string')); @@ -165,7 +164,6 @@ public function testIsAttributeChanged() $this->assertTrue($job->isAttributeChanged('array')); $job = $this->createEntity('Job', Job::class); - /** @noinspection PhpRedundantOptionalArgumentInspection */ $job->set('array', null); $this->assertTrue($job->isAttributeChanged('array')); @@ -190,7 +188,6 @@ public function testIsAttributeChanged() $job = $this->createEntity('Job', Job::class); $job->setFetched('arrayUnordered', ['1', '2']); - /** @noinspection PhpRedundantOptionalArgumentInspection */ $job->set('arrayUnordered', null); $this->assertTrue($job->isAttributeChanged('arrayUnordered')); From 928c08eadb261193b5068b979f41eef2df278983 Mon Sep 17 00:00:00 2001 From: Yurii Date: Sat, 11 Jul 2026 14:06:55 +0300 Subject: [PATCH 09/87] Fix test --- application/Espo/Binding.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/application/Espo/Binding.php b/application/Espo/Binding.php index fed1c8966c8..f2107f4e123 100644 --- a/application/Espo/Binding.php +++ b/application/Espo/Binding.php @@ -247,6 +247,11 @@ private function bindServices(Binder $binder): void 'Espo\\Core\\Session\\Session', 'session' ); + + $binder->bindService( + 'Espo\\Core\\Job\\JobManager', + 'jobManager' + ); } private function bindCore(Binder $binder): void From 0e90e7c996a95969a347610d26009a622f10415a Mon Sep 17 00:00:00 2001 From: Yurii Date: Mon, 13 Jul 2026 11:03:24 +0300 Subject: [PATCH 10/87] CS --- application/Espo/Core/Hook/GeneralInvoker.php | 48 +++++++++---------- application/Espo/Core/HookManager.php | 14 +++--- 2 files changed, 31 insertions(+), 31 deletions(-) diff --git a/application/Espo/Core/Hook/GeneralInvoker.php b/application/Espo/Core/Hook/GeneralInvoker.php index 6f49e2c6a89..0adf6a99825 100644 --- a/application/Espo/Core/Hook/GeneralInvoker.php +++ b/application/Espo/Core/Hook/GeneralInvoker.php @@ -52,15 +52,15 @@ */ class GeneralInvoker { - private const HOOK_BEFORE_SAVE = 'beforeSave'; - private const HOOK_AFTER_SAVE = 'afterSave'; - private const HOOK_LATE_AFTER_SAVE = 'lateAfterSave'; - private const HOOK_BEFORE_REMOVE = 'beforeRemove'; - private const HOOK_AFTER_REMOVE = 'afterRemove'; - private const HOOK_LATE_AFTER_REMOVE = 'lateAfterRemove'; - private const HOOK_AFTER_RELATE = 'afterRelate'; - private const HOOK_AFTER_UNRELATE = 'afterUnrelate'; - private const HOOK_AFTER_MASS_RELATE = 'afterMassRelate'; + private const string HOOK_BEFORE_SAVE = 'beforeSave'; + private const string HOOK_AFTER_SAVE = 'afterSave'; + private const string HOOK_LATE_AFTER_SAVE = 'lateAfterSave'; + private const string HOOK_BEFORE_REMOVE = 'beforeRemove'; + private const string HOOK_AFTER_REMOVE = 'afterRemove'; + private const string HOOK_LATE_AFTER_REMOVE = 'lateAfterRemove'; + private const string HOOK_AFTER_RELATE = 'afterRelate'; + private const string HOOK_AFTER_UNRELATE = 'afterUnrelate'; + private const string HOOK_AFTER_MASS_RELATE = 'afterMassRelate'; /** * @param object $hook A hook object. @@ -74,7 +74,7 @@ public function invoke( string $name, mixed $subject, array $options, - array $hookData + array $hookData, ): void { if ($name === self::HOOK_BEFORE_SAVE && $hook instanceof BeforeSave) { @@ -151,11 +151,11 @@ public function invoke( } $hook->afterRelate( - $subject, - $relationName, - $relatedEntity, - $columnData, - RelateOptions::fromAssoc($options) + entity: $subject, + relationName: $relationName, + relatedEntity: $relatedEntity, + columnData: $columnData, + options: RelateOptions::fromAssoc($options), ); return; @@ -174,10 +174,10 @@ public function invoke( } $hook->afterUnrelate( - $subject, - $relationName, - $relatedEntity, - UnrelateOptions::fromAssoc($options) + entity: $subject, + relationName: $relationName, + relatedEntity: $relatedEntity, + options: UnrelateOptions::fromAssoc($options), ); return; @@ -197,11 +197,11 @@ public function invoke( } $hook->afterMassRelate( - $subject, - $relationName, - $query, - $columnData, - MassRelateOptions::fromAssoc($options) + entity: $subject, + relationName: $relationName, + query: $query, + columnData: $columnData, + options: MassRelateOptions::fromAssoc($options), ); return; diff --git a/application/Espo/Core/HookManager.php b/application/Espo/Core/HookManager.php index 9e857bc7662..e152b6b109b 100644 --- a/application/Espo/Core/HookManager.php +++ b/application/Espo/Core/HookManager.php @@ -51,7 +51,7 @@ */ class HookManager { - private const DEFAULT_ORDER = 9; + private const int DEFAULT_ORDER = 9; /** @var ?array> */ private $data = null; @@ -91,7 +91,7 @@ public function process( string $hookName, mixed $injection = null, array $options = [], - array $hookData = [] + array $hookData = [], ): void { if ($this->isDisabled) { @@ -116,11 +116,11 @@ public function process( $hook = $this->hooks[$className]; $this->generalInvoker->invoke( - $hook, - $hookName, - $injection, - $options, - $hookData + hook: $hook, + name: $hookName, + subject: $injection, + options: $options, + hookData: $hookData, ); } } From 787712cf4497c67373abd49f6ade06d0da9889ad Mon Sep 17 00:00:00 2001 From: Yurii Date: Mon, 13 Jul 2026 11:11:38 +0300 Subject: [PATCH 11/87] Deprecations, todo, CS --- .../Espo/Core/Repositories/Database.php | 26 ++++++++++--------- .../Espo/ORM/Repository/RDBRepository.php | 18 ++++++++++--- 2 files changed, 28 insertions(+), 16 deletions(-) diff --git a/application/Espo/Core/Repositories/Database.php b/application/Espo/Core/Repositories/Database.php index 78aa6be5b3c..a6400377b73 100644 --- a/application/Espo/Core/Repositories/Database.php +++ b/application/Espo/Core/Repositories/Database.php @@ -56,12 +56,12 @@ */ class Database extends RDBRepository { - private const ATTR_ID = 'id'; - private const ATTR_CREATED_BY_ID = Field::CREATED_BY . 'Id'; - private const ATTR_MODIFIED_BY_ID = Field::MODIFIED_BY . 'Id'; - private const ATTR_MODIFIED_BY_NAME = Field::MODIFIED_BY . 'Name'; - private const ATTR_CREATED_AT = Field::CREATED_AT; - private const ATTR_MODIFIED_AT = Field::MODIFIED_AT; + private const string ATTR_ID = 'id'; + private const string ATTR_CREATED_BY_ID = Field::CREATED_BY . 'Id'; + private const string ATTR_MODIFIED_BY_ID = Field::MODIFIED_BY . 'Id'; + private const string ATTR_MODIFIED_BY_NAME = Field::MODIFIED_BY . 'Name'; + private const string ATTR_CREATED_AT = Field::CREATED_AT; + private const string ATTR_MODIFIED_AT = Field::MODIFIED_AT; /** * Disables hook processing. @@ -117,12 +117,6 @@ protected function getMetadata() /** @phpstan-ignore-line */ return $this->metadata; } - /** - * @deprecated Will be removed. - */ - public function handleSelectParams(&$params) /** @phpstan-ignore-line */ - {} - /** * @param TEntity $entity * @param array $options @@ -183,6 +177,8 @@ final protected function lateAfterRemove(Entity $entity, array $options): void * @param TEntity $entity * @param array $options * @return void + * + * @todo Add final in v11.0. */ protected function beforeRemove(Entity $entity, array $options = []) { @@ -222,6 +218,8 @@ protected function beforeRemove(Entity $entity, array $options = []) * @param TEntity $entity * @param array $options * @return void + * + * @todo Add final in v11.0. */ protected function afterRemove(Entity $entity, array $options = []) { @@ -240,6 +238,8 @@ protected function afterRemove(Entity $entity, array $options = []) * @param array $params * @param array $options * @return void + * + * @todo Add final in v11.0. */ protected function afterMassRelate(Entity $entity, $relationName, array $params = [], array $options = []) { @@ -478,6 +478,7 @@ private function processCreatedAndModifiedFieldsSaveNew(Entity $entity, array $o /** * @pparam TEntity $entity * @return mixed + * @noinspection PhpSameParameterValueInspection */ private function getAttributeParam(Entity $entity, string $attribute, string $param) { @@ -499,6 +500,7 @@ private function getAttributeParam(Entity $entity, string $attribute, string $pa /** * @param TEntity $entity * @return mixed + * @noinspection PhpSameParameterValueInspection */ private function getRelationParam(Entity $entity, string $relation, string $param) { diff --git a/application/Espo/ORM/Repository/RDBRepository.php b/application/Espo/ORM/Repository/RDBRepository.php index d69aab17aaa..7187c74b1fd 100644 --- a/application/Espo/ORM/Repository/RDBRepository.php +++ b/application/Espo/ORM/Repository/RDBRepository.php @@ -453,7 +453,7 @@ public function sth(): RDBSelectBuilder * * `where(string $key, string $value)` * * @param WhereItem|array|string $clause A key or where clause. - * @param mixed[]|scalar|null $value A value. Should be omitted if the first argument is not string. + * @param array|scalar|null $value A value. Should be omitted if the first argument is not string. * @return RDBSelectBuilder> */ public function where($clause = [], $value = null): RDBSelectBuilder @@ -470,7 +470,7 @@ public function where($clause = [], $value = null): RDBSelectBuilder * * `having(string $key, string $value)` * * @param WhereItem|array|string $clause A key or where clause. - * @param mixed[]|scalar|null $value A value. Should be omitted if the first argument is not string. + * @param array|scalar|null $value A value. Should be omitted if the first argument is not string. * @return RDBSelectBuilder> */ public function having($clause = [], $value = null): RDBSelectBuilder @@ -561,7 +561,8 @@ public function createBuilder(): RDBSelectBuilder } /** - * Use hooks instead. + * @deprecated Use hooks instead. + * @todo Change signature in v11.0. Add void. * * @param array $options * @return void @@ -573,6 +574,7 @@ protected function beforeSave(Entity $entity, array $options = []) /** * @deprecated Use hooks instead. + * @todo Change signature in v11.0. Add void. * * @param array $options * @return void @@ -584,6 +586,7 @@ protected function afterSave(Entity $entity, array $options = []) /** * @deprecated Use hooks instead. + * @todo Change signature in v11.0. Add void. * * @param array $options * @return void @@ -595,6 +598,7 @@ protected function beforeRemove(Entity $entity, array $options = []) /** * @deprecated Use hooks instead. + * @todo Change signature in v11.0. Add void. * * @param array $options * @return void @@ -617,6 +621,7 @@ protected function getMapper(): RDBMapper /** * @deprecated As of v6.0. Use hooks instead. + * @todo Remove in v11.0. * @phpstan-ignore-next-line */ protected function beforeRelate(Entity $entity, $relationName, $foreign, $data = null, array $options = []) @@ -624,6 +629,7 @@ protected function beforeRelate(Entity $entity, $relationName, $foreign, $data = /** * @deprecated As of v6.0. Use hooks instead. + * @todo Change signature in v11.0. Add void. * @phpstan-ignore-next-line */ protected function afterRelate(Entity $entity, $relationName, $foreign, $data = null, array $options = []) @@ -631,6 +637,7 @@ protected function afterRelate(Entity $entity, $relationName, $foreign, $data = /** * @deprecated As of v6.0. Use hooks instead. + * @todo Remove in v11.0. * @phpstan-ignore-next-line */ protected function beforeUnrelate(Entity $entity, $relationName, $foreign, array $options = []) @@ -638,6 +645,7 @@ protected function beforeUnrelate(Entity $entity, $relationName, $foreign, array /** * @deprecated As of v6.0. Use hooks instead. + * @todo Change signature in v11.0. Add void. * @phpstan-ignore-next-line */ protected function afterUnrelate(Entity $entity, $relationName, $foreign, array $options = []) @@ -645,6 +653,7 @@ protected function afterUnrelate(Entity $entity, $relationName, $foreign, array /** * @deprecated As of v6.0. Use hooks instead. + * @todo Remove in v11.0. * @phpstan-ignore-next-line */ protected function beforeMassRelate(Entity $entity, $relationName, array $params = [], array $options = []) @@ -652,6 +661,7 @@ protected function beforeMassRelate(Entity $entity, $relationName, array $params /** * @deprecated As of v6.0. Use hooks instead. + * @todo Change signature in v11.0. Add void. * @phpstan-ignore-next-line */ protected function afterMassRelate(Entity $entity, $relationName, array $params = [], array $options = []) @@ -718,7 +728,7 @@ private function saveSetRelationHasOne(Entity $entity, string $name, ?Entity $re ->findOne(); if (!$entity->isNew()) { - $entity->setFetched($idAttribute, $previous ? $previous->getId() : null); + $entity->setFetched($idAttribute, $previous?->getId()); } if ($previous) { From 80f043354ea6a79923ba0fa6eef7f45402250544 Mon Sep 17 00:00:00 2001 From: Yurii Date: Mon, 13 Jul 2026 11:30:47 +0300 Subject: [PATCH 12/87] RDBRelation collection generic type --- .../Espo/ORM/Repository/RDBRelation.php | 26 ++++++------ .../Repository/RDBRelationSelectBuilder.php | 41 +++++++++++-------- 2 files changed, 37 insertions(+), 30 deletions(-) diff --git a/application/Espo/ORM/Repository/RDBRelation.php b/application/Espo/ORM/Repository/RDBRelation.php index 9bad9707f2e..edb832696de 100644 --- a/application/Espo/ORM/Repository/RDBRelation.php +++ b/application/Espo/ORM/Repository/RDBRelation.php @@ -98,7 +98,7 @@ public function __construct( /** * Create a select builder. * - * @return Builder + * @return Builder> * * @since 9.2.5 */ @@ -228,7 +228,7 @@ public function count(): int * A relation name or table. A relation name should be in camelCase, a table in CamelCase. * @param string|null $alias An alias. * @param WhereItem|array|null $conditions Join conditions. - * @return Builder + * @return Builder> */ public function join($target, ?string $alias = null, $conditions = null): Builder { @@ -242,7 +242,7 @@ public function join($target, ?string $alias = null, $conditions = null): Builde * A relation name or table. A relation name should be in camelCase, a table in CamelCase. * @param string|null $alias An alias. * @param WhereItem|array|null $conditions Join conditions. - * @return Builder + * @return Builder> */ public function leftJoin($target, ?string $alias = null, $conditions = null): Builder { @@ -252,7 +252,7 @@ public function leftJoin($target, ?string $alias = null, $conditions = null): Bu /** * Set DISTINCT parameter. * - * @return Builder + * @return Builder> */ public function distinct(): Builder { @@ -262,7 +262,7 @@ public function distinct(): Builder /** * Set to return STH collection. Recommended for fetching large number of records. * - * @return Builder + * @return Builder> */ public function sth(): Builder { @@ -279,7 +279,7 @@ public function sth(): Builder * * @param WhereItem|array|string $clause A key or where clause. * @param array|scalar|null $value A value. Should be omitted if the first argument is not string. - * @return Builder + * @return Builder> */ public function where($clause = [], $value = null): Builder { @@ -296,7 +296,7 @@ public function where($clause = [], $value = null): Builder * * @param WhereItem|array|string $clause A key or where clause. * @param array|string|null $value A value. Should be omitted if the first argument is not string. - * @return Builder + * @return Builder> */ public function having($clause = [], $value = null): Builder { @@ -316,7 +316,7 @@ public function having($clause = [], $value = null): Builder * An attribute to order by or an array or order items. * Passing an array will reset a previously set order. * @param (Order::ASC|Order::DESC)|bool|null $direction A direction. - * @return Builder + * @return Builder> */ public function order($orderBy = Attribute::ID, $direction = null): Builder { @@ -326,7 +326,7 @@ public function order($orderBy = Attribute::ID, $direction = null): Builder /** * Apply OFFSET and LIMIT. * - * @return Builder + * @return Builder> */ public function limit(?int $offset = null, ?int $limit = null): Builder { @@ -346,7 +346,7 @@ public function limit(?int $offset = null, ?int $limit = null): Builder * @param Selection|Selection[]|Expression|Expression[]|string[]|string|array $select * An array of expressions or one expression. * @param string|null $alias An alias. Actual if the first parameter is not an array. - * @return Builder + * @return Builder> */ public function select($select = [], ?string $alias = null): Builder { @@ -363,7 +363,7 @@ public function select($select = [], ?string $alias = null): Builder * * `groupBy([$expr1, $expr2, ...])` * * @param Expression|Expression[]|string|string[] $groupBy - * @return Builder + * @return Builder> */ public function group($groupBy): Builder { @@ -373,7 +373,7 @@ public function group($groupBy): Builder /** * @deprecated Use `group` method. * @param Expression|Expression[]|string|string[] $groupBy - * @return Builder + * @return Builder> */ public function groupBy($groupBy): Builder { @@ -387,7 +387,7 @@ public function groupBy($groupBy): Builder * `->columnsWhere(['column' => $value])` * * @param WhereItem|array $clause Where clause. - * @return Builder + * @return Builder> */ public function columnsWhere($clause): Builder { diff --git a/application/Espo/ORM/Repository/RDBRelationSelectBuilder.php b/application/Espo/ORM/Repository/RDBRelationSelectBuilder.php index 61b041a06d5..ea8b03faf9f 100644 --- a/application/Espo/ORM/Repository/RDBRelationSelectBuilder.php +++ b/application/Espo/ORM/Repository/RDBRelationSelectBuilder.php @@ -49,11 +49,13 @@ use LogicException; use RuntimeException; use InvalidArgumentException; +use UnexpectedValueException; /** * Builds select parameters for related records for RDB repository. * * @template TEntity of Entity = Entity + * @template TCollection of EntityCollection|SthCollection = EntityCollection */ class RDBRelationSelectBuilder { @@ -128,7 +130,7 @@ private function getMapper(): RDBMapper * `->columnsWhere(['column' => $value])` * * @param WhereItem|array $clause Where clause. - * @return self + * @return self */ public function columnsWhere($clause): self { @@ -187,7 +189,7 @@ private function applyMiddleAliasToWhere(array $where): array /** * Find related records by a criteria. * - * @return EntityCollection|SthCollection + * @return TCollection */ public function find(): EntityCollection|SthCollection { @@ -198,7 +200,7 @@ public function find(): EntityCollection|SthCollection if ($related instanceof Collection) { /** @var Collection $related */ - /** @var EntityCollection|SthCollection */ + /** @var TCollection */ return $this->handleReturnCollection($related); } @@ -211,6 +213,7 @@ public function find(): EntityCollection|SthCollection $collection[] = $related; } + /** @var TCollection */ return $collection; } @@ -259,7 +262,7 @@ public function count(): int * A relation name or table. A relation name should be in camelCase, a table in CamelCase. * @param string|null $alias An alias. * @param WhereItem|array|null $conditions Join conditions. - * @return self + * @return self */ public function join($target, ?string $alias = null, $conditions = null): self { @@ -275,7 +278,7 @@ public function join($target, ?string $alias = null, $conditions = null): self * A relation name or table. A relation name should be in camelCase, a table in CamelCase. * @param string|null $alias An alias. * @param WhereItem|array|null $conditions Join conditions. - * @return self + * @return self */ public function leftJoin($target, ?string $alias = null, $conditions = null): self { @@ -287,7 +290,7 @@ public function leftJoin($target, ?string $alias = null, $conditions = null): se /** * Set DISTINCT parameter. * - * @return self + * @return self */ public function distinct(): self { @@ -299,7 +302,7 @@ public function distinct(): self /** * Return STH collection. Recommended for fetching large number of records. * - * @return self + * @return self> */ public function sth(): self { @@ -318,7 +321,7 @@ public function sth(): self * * @param WhereItem|array|string $clause A key or where clause. * @param array|scalar|null $value A value. Should be omitted if the first argument is not string. - * @return self + * @return self */ public function where($clause = [], $value = null): self { @@ -347,7 +350,7 @@ public function where($clause = [], $value = null): self * * @param WhereItem|array|string $clause A key or where clause. * @param array|string|null $value A value. Should be omitted if the first argument is not string. - * @return self + * @return self */ public function having($clause = [], $value = null): self { @@ -369,7 +372,7 @@ public function having($clause = [], $value = null): self * An attribute to order by or an array or order items. * Passing an array will reset a previously set order. * @param (Order::ASC|Order::DESC)|bool|null $direction Select::ORDER_ASC|Select::ORDER_DESC. - * @return self + * @return self */ public function order($orderBy = Attribute::ID, $direction = null): self { @@ -381,7 +384,7 @@ public function order($orderBy = Attribute::ID, $direction = null): self /** * Apply OFFSET and LIMIT. * - * @return self + * @return self */ public function limit(?int $offset = null, ?int $limit = null): self { @@ -403,7 +406,7 @@ public function limit(?int $offset = null, ?int $limit = null): self * @param Selection|Selection[]|Expression|Expression[]|string[]|string|array $select * An array of expressions or one expression. * @param string|null $alias An alias. Actual if the first parameter is not an array. - * @return self + * @return self */ public function select($select, ?string $alias = null): self { @@ -422,7 +425,7 @@ public function select($select, ?string $alias = null): self * * `groupBy([$expr1, $expr2, ...])` * * @param Expression|Expression[]|string|string[] $groupBy - * @return self + * @return self */ public function group($groupBy): self { @@ -434,7 +437,7 @@ public function group($groupBy): self /** * @deprecated Use `group` method. * @param Expression|Expression[]|string|string[] $groupBy - * @return self + * @return self */ public function groupBy($groupBy): self { @@ -508,10 +511,14 @@ private function isManyMany(): bool /** * @param Collection $collection - * @return Collection + * @return EntityCollection|SthCollection */ - private function handleReturnCollection(Collection $collection): Collection + private function handleReturnCollection(Collection $collection): EntityCollection|SthCollection { + if (!$collection instanceof EntityCollection && !$collection instanceof SthCollection) { + throw new UnexpectedValueException(); + } + if (!$collection instanceof SthCollection) { return $collection; } @@ -520,7 +527,7 @@ private function handleReturnCollection(Collection $collection): Collection return $collection; } - /** @var Collection */ + /** @var EntityCollection */ return $this->entityManager->getCollectionFactory()->createFromSthCollection($collection); } From 1544b54cfa5be84466d6fe81df005d81a1b5821b Mon Sep 17 00:00:00 2001 From: Yurii Date: Mon, 13 Jul 2026 12:53:44 +0300 Subject: [PATCH 13/87] Hook data provider --- application/Espo/Binding.php | 5 + application/Espo/Core/DataManager.php | 9 +- application/Espo/Core/Hook/Control.php | 60 +++++ application/Espo/Core/Hook/DataProvider.php | 225 +++++++++++++++++ application/Espo/Core/HookManager.php | 160 +----------- .../Espo/Core/Loaders/HookDataProvider.php | 49 ++++ .../DataProviderTest.php} | 234 ++++++++---------- 7 files changed, 462 insertions(+), 280 deletions(-) create mode 100644 application/Espo/Core/Hook/Control.php create mode 100644 application/Espo/Core/Hook/DataProvider.php create mode 100644 application/Espo/Core/Loaders/HookDataProvider.php rename tests/unit/Espo/Core/{HookManagerTest.php => Hook/DataProviderTest.php} (68%) diff --git a/application/Espo/Binding.php b/application/Espo/Binding.php index f2107f4e123..d3d614f8282 100644 --- a/application/Espo/Binding.php +++ b/application/Espo/Binding.php @@ -143,6 +143,11 @@ private function bindServices(Binder $binder): void 'recordServiceContainer' ); + $binder->bindService( + 'Espo\\Core\\Hook\\DataProvider', + 'hookDataProvider' + ); + $binder->bindService( 'Espo\\Core\\HookManager', 'hookManager' diff --git a/application/Espo/Core/DataManager.php b/application/Espo/Core/DataManager.php index 98a00e531b0..802cc02ed44 100644 --- a/application/Espo/Core/DataManager.php +++ b/application/Espo/Core/DataManager.php @@ -30,6 +30,7 @@ namespace Espo\Core; use Espo\Core\Exceptions\Error; +use Espo\Core\Hook\Control; use Espo\Core\ORM\EntityManagerProxy; use Espo\Core\Utils\Database\Helper as DatabaseHelper; use Espo\Core\Utils\Database\Schema\RebuildMode; @@ -61,7 +62,6 @@ public function __construct( private ConfigWriter $configWriter, private Metadata $metadata, private OrmMetadataData $ormMetadataData, - private HookManager $hookManager, private SchemaManagerProxy $schemaManager, private Log $log, private Module $module, @@ -69,7 +69,8 @@ public function __construct( private ConfigMissingDefaultParamsSaver $configMissingDefaultParamsSaver, private FileManager $fileManager, private DatabaseParamsFactory $databaseParamsFactory, - private InjectableFactory $injectableFactory + private InjectableFactory $injectableFactory, + private Control $hookControl, ) {} /** @@ -237,12 +238,12 @@ private function setCryptKeyConfigParameter(): void private function disableHooks(): void { - $this->hookManager->disable(); + $this->hookControl->disable(); } private function enableHooks(): void { - $this->hookManager->enable(); + $this->hookControl->enable(); } /** diff --git a/application/Espo/Core/Hook/Control.php b/application/Espo/Core/Hook/Control.php new file mode 100644 index 00000000000..aec35d74ac8 --- /dev/null +++ b/application/Espo/Core/Hook/Control.php @@ -0,0 +1,60 @@ +. + * + * 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\Hook; + +use Espo\Core\Container; +use Espo\Core\HookManager; + +/** + * @internal + * + * Serves as a proxy for `HookManager`. + */ +class Control +{ + public function __construct( + private Container $container, + ) {} + + public function enable(): void + { + $this->getHookManager()->enable(); + } + + public function disable(): void + { + $this->getHookManager()->disable(); + } + + private function getHookManager(): HookManager + { + return $this->container->getByClass(HookManager::class); + } +} diff --git a/application/Espo/Core/Hook/DataProvider.php b/application/Espo/Core/Hook/DataProvider.php new file mode 100644 index 00000000000..2d655af91dd --- /dev/null +++ b/application/Espo/Core/Hook/DataProvider.php @@ -0,0 +1,225 @@ +. + * + * 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\Hook; + +use Espo\Core\Utils\Config\SystemConfig; +use Espo\Core\Utils\DataCache; +use Espo\Core\Utils\File\Manager as FileManager; +use Espo\Core\Utils\Metadata; +use Espo\Core\Utils\Module\PathProvider; +use Espo\Core\Utils\Util; +use LogicException; + +/** + * @internal + * @since 10.1.0 + * + * To be re-used between requests. + */ +class DataProvider +{ + private const int DEFAULT_ORDER = 9; + + /** @var ?array> */ + private $data = null; + + private string $cacheKey = 'hooks'; + + /** @var string[] */ + private $ignoredMethodList = [ + '__construct', + 'getDependencyList', + 'inject', + ]; + + public function __construct( + private SystemConfig $systemConfig, + private FileManager $fileManager, + private PathProvider $pathProvider, + private DataCache $dataCache, + private Metadata $metadata, + ) {} + + /** + * @return array> + */ + public function get(): array + { + if (!$this->data) { + $this->load(); + } + + return $this->data ?? throw new LogicException(); + } + + private function load(): void + { + if ($this->systemConfig->useCache() && $this->dataCache->has($this->cacheKey)) { + /** @var array> $cachedData */ + $cachedData = $this->dataCache->get($this->cacheKey); + + $this->data = $cachedData; + + return; + } + + $data = $this->readHookData($this->pathProvider->getCustom() . 'Hooks'); + + foreach ($this->metadata->getModuleList() as $moduleName) { + $modulePath = $this->pathProvider->getModule($moduleName) . 'Hooks'; + + $data = $this->readHookData($modulePath, $data); + } + + $data = $this->readHookData($this->pathProvider->getCore() . 'Hooks', $data); + + $this->data = $this->sortHooks($data); + + if ($this->systemConfig->useCache()) { + $this->dataCache->store($this->cacheKey, $this->data); + } + } + + /** + * @param string $hookDir + * @param array> $hookData + * @return array> + */ + private function readHookData(string $hookDir, array $hookData = []): array + { + if (!$this->fileManager->exists($hookDir)) { + return $hookData; + } + + /** @var array $fileList */ + $fileList = $this->fileManager->getFileList($hookDir, 1, '\.php$', true); + + foreach ($fileList as $scopeName => $hookFiles) { + $hookScopeDirPath = Util::concatPath($hookDir, $scopeName); + $normalizedScopeName = Util::normalizeScopeName($scopeName); + + foreach ($hookFiles as $hookFile) { + $hookFilePath = Util::concatPath($hookScopeDirPath, $hookFile); + $className = Util::getClassName($hookFilePath); + + $classMethods = get_class_methods($className); + + $hookMethods = array_diff($classMethods, $this->ignoredMethodList); + + /** @var string[] $hookMethods */ + $hookMethods = array_filter($hookMethods, function ($item) { + if (str_starts_with($item, 'set')) { + return false; + } + + return true; + }); + + foreach ($hookMethods as $hookType) { + $entityHookData = $hookData[$normalizedScopeName][$hookType] ?? []; + + if ($this->hookExists($className, $entityHookData)) { + continue; + } + + if ($this->hookClassIsSuppressed($className)) { + continue; + } + + $hookData[$normalizedScopeName][$hookType][] = [ + 'className' => $className, + 'order' => $className::$order ?? self::DEFAULT_ORDER, + ]; + } + } + } + + return $hookData; + } + + /** + * Check if hook exists in the list. + * + * @param class-string $className + * @param array $hookData + */ + private function hookExists(string $className, array $hookData): bool + { + $class = preg_replace('/^.*\\\(.*)$/', '$1', $className); + + foreach ($hookData as $item) { + if (preg_match('/\\\\'.$class.'$/', $item['className'])) { + return true; + } + } + + return false; + } + + /** + * Sort hooks by the order parameter. + * + * @param array> $hooks + * @return array> + */ + private function sortHooks(array $hooks): array + { + foreach ($hooks as &$scopeHooks) { + foreach ($scopeHooks as &$hookList) { + usort($hookList, [$this, 'cmpHooks']); + } + } + + return $hooks; + } + + /** + * @param class-string $className + */ + private function hookClassIsSuppressed(string $className): bool + { + $suppressList = $this->metadata->get(['app', 'hook', 'suppressClassNameList']) ?? []; + + return in_array($className, $suppressList); + } + + /** + * @param array $a + * @param array $b + */ + private function cmpHooks($a, $b): int + { + if ($a['order'] == $b['order']) { + return 0; + } + + return ($a['order'] < $b['order']) ? -1 : 1; + } +} diff --git a/application/Espo/Core/HookManager.php b/application/Espo/Core/HookManager.php index e152b6b109b..a8a73adafff 100644 --- a/application/Espo/Core/HookManager.php +++ b/application/Espo/Core/HookManager.php @@ -29,14 +29,9 @@ namespace Espo\Core; +use Espo\Core\Hook\DataProvider; use Espo\Core\Hook\GeneralInvoker; -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\Metadata; -use Espo\Core\Utils\Module\PathProvider; -use Espo\Core\Utils\Util; /** * Runs hooks. E.g. beforeSave, afterSave. Hooks can be located in a folder @@ -51,32 +46,22 @@ */ class HookManager { - private const int DEFAULT_ORDER = 9; - /** @var ?array> */ private $data = null; + private bool $isDisabled = false; + /** @var array */ private $hookListHash = []; + /** @var array */ private $hooks; - private string $cacheKey = 'hooks'; - /** @var string[] */ - private $ignoredMethodList = [ - '__construct', - 'getDependencyList', - 'inject', - ]; public function __construct( private InjectableFactory $injectableFactory, - private FileManager $fileManager, - private Metadata $metadata, - private DataCache $dataCache, private Log $log, - private PathProvider $pathProvider, private GeneralInvoker $generalInvoker, - private SystemConfig $systemConfig, + private DataProvider $dataProvider, ) {} /** @@ -98,13 +83,13 @@ public function process( return; } - if (!isset($this->data)) { + if ($this->data === null) { $this->loadHooks(); } $hookList = $this->getHookList($scope, $hookName); - if (empty($hookList)) { + if ($hookList === []) { return; } @@ -143,32 +128,7 @@ public function enable(): void private function loadHooks(): void { - if ($this->systemConfig->useCache() && $this->dataCache->has($this->cacheKey)) { - /** @var array> $cachedData */ - $cachedData = $this->dataCache->get($this->cacheKey); - - $this->data = $cachedData; - - return; - } - - $metadata = $this->metadata; - - $data = $this->readHookData($this->pathProvider->getCustom() . 'Hooks'); - - foreach ($metadata->getModuleList() as $moduleName) { - $modulePath = $this->pathProvider->getModule($moduleName) . 'Hooks'; - - $data = $this->readHookData($modulePath, $data); - } - - $data = $this->readHookData($this->pathProvider->getCore() . 'Hooks', $data); - - $this->data = $this->sortHooks($data); - - if ($this->systemConfig->useCache()) { - $this->dataCache->store($this->cacheKey, $this->data); - } + $this->data = $this->dataProvider->get(); } /** @@ -183,89 +143,6 @@ private function createHookByClassName(string $className): object return $this->injectableFactory->create($className); } - /** - * @param string $hookDir - * @param array> $hookData - * @return array> - */ - private function readHookData(string $hookDir, array $hookData = []): array - { - if (!$this->fileManager->exists($hookDir)) { - return $hookData; - } - - /** @var array $fileList */ - $fileList = $this->fileManager->getFileList($hookDir, 1, '\.php$', true); - - foreach ($fileList as $scopeName => $hookFiles) { - $hookScopeDirPath = Util::concatPath($hookDir, $scopeName); - $normalizedScopeName = Util::normalizeScopeName($scopeName); - - foreach ($hookFiles as $hookFile) { - $hookFilePath = Util::concatPath($hookScopeDirPath, $hookFile); - $className = Util::getClassName($hookFilePath); - - $classMethods = get_class_methods($className); - - $hookMethods = array_diff($classMethods, $this->ignoredMethodList); - - /** @var string[] $hookMethods */ - $hookMethods = array_filter($hookMethods, function ($item) { - if (str_starts_with($item, 'set')) { - return false; - } - - return true; - }); - - foreach ($hookMethods as $hookType) { - $entityHookData = $hookData[$normalizedScopeName][$hookType] ?? []; - - if ($this->hookExists($className, $entityHookData)) { - continue; - } - - if ($this->hookClassIsSuppressed($className)) { - continue; - } - - $hookData[$normalizedScopeName][$hookType][] = [ - 'className' => $className, - 'order' => $className::$order ?? self::DEFAULT_ORDER, - ]; - } - } - } - - return $hookData; - } - - /** - * @param class-string $className - */ - private function hookClassIsSuppressed(string $className): bool - { - $suppressList = $this->metadata->get(['app', 'hook', 'suppressClassNameList']) ?? []; - - return in_array($className, $suppressList); - } - - /** - * Sort hooks by the order parameter. - * - * @param array> $hooks - * @return array> - */ - private function sortHooks(array $hooks): array - { - foreach ($hooks as &$scopeHooks) { - foreach ($scopeHooks as &$hookList) { - usort($hookList, [$this, 'cmpHooks']); - } - } - - return $hooks; - } /** * Get sorted hook list. @@ -286,7 +163,7 @@ private function getHookList(string $scope, string $hookName): array if (isset($this->data[$scope][$hookName])) { $hookList = array_merge($hookList, $this->data[$scope][$hookName]); - usort($hookList, array($this, 'cmpHooks')); + usort($hookList, [$this, 'cmpHooks']); } $normalizedList = []; @@ -301,25 +178,6 @@ private function getHookList(string $scope, string $hookName): array return $this->hookListHash[$key]; } - /** - * Check if hook exists in the list. - * - * @param class-string $className - * @param array $hookData - */ - private function hookExists(string $className, array $hookData): bool - { - $class = preg_replace('/^.*\\\(.*)$/', '$1', $className); - - foreach ($hookData as $item) { - if (preg_match('/\\\\'.$class.'$/', $item['className'])) { - return true; - } - } - - return false; - } - /** * @param array $a * @param array $b diff --git a/application/Espo/Core/Loaders/HookDataProvider.php b/application/Espo/Core/Loaders/HookDataProvider.php new file mode 100644 index 00000000000..cd22bb23c5f --- /dev/null +++ b/application/Espo/Core/Loaders/HookDataProvider.php @@ -0,0 +1,49 @@ +. + * + * 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\Loaders; + +use Espo\Core\Container\Loader; +use Espo\Core\Hook\DataProvider; +use Espo\Core\InjectableFactory; + +/** + * @noinspection PhpUnused + */ +class HookDataProvider implements Loader +{ + public function __construct( + private InjectableFactory $injectableFactory, + ) {} + + public function load() + { + return $this->injectableFactory->create(DataProvider::class); + } +} diff --git a/tests/unit/Espo/Core/HookManagerTest.php b/tests/unit/Espo/Core/Hook/DataProviderTest.php similarity index 68% rename from tests/unit/Espo/Core/HookManagerTest.php rename to tests/unit/Espo/Core/Hook/DataProviderTest.php index 6a4db157d80..be5e19e9f1f 100644 --- a/tests/unit/Espo/Core/HookManagerTest.php +++ b/tests/unit/Espo/Core/Hook/DataProviderTest.php @@ -27,30 +27,25 @@ * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. ************************************************************************/ -namespace tests\unit\Espo\Core; +namespace tests\unit\Espo\Core\Hook; +use Espo\Core\Hook\DataProvider; use PHPUnit\Framework\TestCase; use tests\unit\ReflectionHelper; - -use Espo\Core\Hook\GeneralInvoker; -use Espo\Core\HookManager; -use Espo\Core\InjectableFactory; use Espo\Core\Utils\Config; use Espo\Core\Utils\DataCache; use Espo\Core\Utils\File\Manager as FileManager; -use Espo\Core\Utils\Log; use Espo\Core\Utils\Metadata; use Espo\Core\Utils\Module\PathProvider; -class HookManagerTest extends TestCase +class DataProviderTest extends TestCase { - private $filesPath = 'tests/unit/testData/Hooks'; + private string $filesPath = 'tests/unit/testData/Hooks'; private ?Config\SystemConfig $systemConfig = null; - - private $pathProvider; - private $reflection; - private $metadata; + private ?PathProvider $pathProvider = null; + private ?ReflectionHelper $reflectionHelper = null; + private ?Metadata $metadata = null; protected function setUp(): void { @@ -59,24 +54,19 @@ protected function setUp(): void $this->systemConfig = $this->createMock(Config\SystemConfig::class); - $injectableFactory = $this->createMock(InjectableFactory::class); $dataCache = $this->createMock(DataCache::class); $fileManager = new FileManager(); $this->pathProvider = $this->createMock(PathProvider::class); - $generalInvoker = $this->createMock(GeneralInvoker::class); - - $hookManager = new HookManager( - $injectableFactory, - $fileManager, - $this->metadata, - $dataCache, - $this->createMock(Log::class), - $this->pathProvider, - $generalInvoker, - $this->systemConfig, + + $dataProvider = new DataProvider( + systemConfig: $this->systemConfig, + fileManager: $fileManager, + pathProvider: $this->pathProvider, + dataCache: $dataCache, + metadata: $this->metadata, ); - $this->reflection = new ReflectionHelper($hookManager); + $this->reflectionHelper = new ReflectionHelper($dataProvider); } private function initPathProvider(string $folder): void @@ -106,156 +96,150 @@ function (?string $moduleName) use ($folder): string { public function testHookExists(): void { - $data = array ( - 'Espo\\Hooks\\Note\\Stream' => 8, - 'Espo\\Hooks\\Note\\Mentions' => 9, - 'Espo\\Hooks\\Note\\Notifications' => 14, - ); - - $data = array ( - array ( + $data = [ + [ 'className' => 'Espo\\Hooks\\Note\\Stream', 'order' => 8, - ), - array ( + ], + [ 'className' => 'Espo\\Hooks\\Note\\Mentions', 'order' => 9, - ), - array ( + ], + [ 'className' => 'Espo\\Hooks\\Note\\Notifications', 'order' => 14, - ), - ); + ], + ]; $this->assertTrue( - $this->reflection->invokeMethod('hookExists', array('Espo\\Hooks\\Note\\Mentions', $data)) + $this->reflectionHelper->invokeMethod('hookExists', ['Espo\\Hooks\\Note\\Mentions', $data]) ); $this->assertTrue( - $this->reflection->invokeMethod('hookExists', array('Espo\\Modules\\Crm\\Hooks\\Note\\Mentions', $data)) + $this->reflectionHelper->invokeMethod('hookExists', ['Espo\\Modules\\Crm\\Hooks\\Note\\Mentions', $data]) ); $this->assertTrue( - $this->reflection->invokeMethod('hookExists', array('Espo\\Modules\\Test\\Hooks\\Note\\Mentions', $data)) + $this->reflectionHelper->invokeMethod('hookExists', ['Espo\\Modules\\Test\\Hooks\\Note\\Mentions', $data]) ); $this->assertTrue( - $this->reflection->invokeMethod('hookExists', array('Espo\\Modules\\Test\\Hooks\\Common\\Stream', $data)) + $this->reflectionHelper->invokeMethod('hookExists', ['Espo\\Modules\\Test\\Hooks\\Common\\Stream', $data]) ); $this->assertFalse( - $this->reflection->invokeMethod('hookExists', array('Espo\\Hooks\\Note\\TestHook', $data)) + $this->reflectionHelper->invokeMethod('hookExists', ['Espo\\Hooks\\Note\\TestHook', $data]) ); } - public function testSortHooks() + public function testSortHooks(): void { - $data = array ( + $data = [ 'Common' => - array ( + [ 'afterSave' => - array ( - array ( + [ + [ 'className' => 'Espo\\Hooks\\Common\\AssignmentEmailNotification', 'order' => 9, - ), - array ( + ], + [ 'className' => 'Espo\\Hooks\\Common\\Notifications', 'order' => 10, - ), - array ( + ], + [ 'className' => 'Espo\\Hooks\\Common\\Stream', 'order' => 9, - ), - ), + ], + ], 'beforeSave' => - array ( - array ( + [ + [ 'className' => 'Espo\\Hooks\\Common\\Formula', 'order' => 5, - ), - array ( + ], + [ 'className' => 'Espo\\Hooks\\Common\\NextNumber', 'order' => 10, - ), - array ( + ], + [ 'className' => 'Espo\\Hooks\\Common\\CurrencyConverted', 'order' => 1, - ), - ), - ), + ], + ], + ], 'Note' => - array ( + [ 'beforeSave' => - array ( - array ( + [ + [ 'className' => 'Espo\\Hooks\\Note\\Mentions', 'order' => 9, - ), - ), + ], + ], 'afterSave' => - array ( - array ( + [ + [ 'className' => 'Espo\\Hooks\\Note\\Notifications', 'order' => 14, - ), - ), - ), - ); + ], + ], + ], + ]; - $result = array ( + $result = [ 'Common' => - array ( + [ 'afterSave' => - array ( - array ( + [ + [ 'className' => 'Espo\\Hooks\\Common\\AssignmentEmailNotification', 'order' => 9, - ), - array ( + ], + [ 'className' => 'Espo\\Hooks\\Common\\Stream', 'order' => 9, - ), - array ( + ], + [ 'className' => 'Espo\\Hooks\\Common\\Notifications', 'order' => 10, - ), - ), + ], + ], 'beforeSave' => - array ( - array ( + [ + [ 'className' => 'Espo\\Hooks\\Common\\CurrencyConverted', 'order' => 1, - ), - array ( + ], + [ 'className' => 'Espo\\Hooks\\Common\\Formula', 'order' => 5, - ), - array ( + ], + [ 'className' => 'Espo\\Hooks\\Common\\NextNumber', 'order' => 10, - ), - ), - ), + ], + ], + ], 'Note' => - array ( + [ 'beforeSave' => - array ( - array ( + [ + [ 'className' => 'Espo\\Hooks\\Note\\Mentions', 'order' => 9, - ), - ), + ], + ], 'afterSave' => - array ( - array ( + [ + [ 'className' => 'Espo\\Hooks\\Note\\Notifications', 'order' => 14, - ), - ), - ), - ); + ], + ], + ], + ]; - $this->assertEquals($result, $this->reflection->invokeMethod('sortHooks', array($data)) ); + $this->assertEquals($result, $this->reflectionHelper->invokeMethod('sortHooks', [$data])); } - public function testCase1CustomHook() + public function testCase1CustomHook(): void { $this->initPathProvider('testCase1'); @@ -272,7 +256,7 @@ public function testCase1CustomHook() 'Test', ]); - $this->reflection->invokeMethod('loadHooks'); + $this->reflectionHelper->invokeMethod('load'); $result = [ 'Note' => @@ -287,10 +271,10 @@ public function testCase1CustomHook() ], ]; - $this->assertEquals($result, $this->reflection->getProperty('data')); + $this->assertEquals($result, $this->reflectionHelper->getProperty('data')); } - public function testCase2ModuleHook1() + public function testCase2ModuleHook1(): void { $this->initPathProvider('testCase2'); @@ -307,7 +291,7 @@ public function testCase2ModuleHook1() 'Test', ]); - $this->reflection->invokeMethod('loadHooks'); + $this->reflectionHelper->invokeMethod('load'); $result = [ 'Note' => @@ -323,10 +307,10 @@ public function testCase2ModuleHook1() ], ]; - $this->assertEquals($result, $this->reflection->getProperty('data')); + $this->assertEquals($result, $this->reflectionHelper->getProperty('data')); } - public function testCase2ModuleHookReverseModuleOrder() + public function testCase2ModuleHookReverseModuleOrder(): void { $this->initPathProvider('testCase2'); @@ -343,7 +327,7 @@ public function testCase2ModuleHookReverseModuleOrder() 'Crm', ]); - $this->reflection->invokeMethod('loadHooks'); + $this->reflectionHelper->invokeMethod('load'); $result = [ 'Note' => @@ -359,7 +343,7 @@ public function testCase2ModuleHookReverseModuleOrder() ], ]; - $this->assertEquals($result, $this->reflection->getProperty('data')); + $this->assertEquals($result, $this->reflectionHelper->getProperty('data')); } public function testCase3CoreHook() @@ -376,21 +360,21 @@ public function testCase3CoreHook() ->method('getModuleList') ->willReturn([]); - $this->reflection->invokeMethod('loadHooks'); + $this->reflectionHelper->invokeMethod('load'); - $result = array ( + $result = [ 'Note' => - array ( + [ 'beforeSave' => - array ( - array ( + [ + [ 'className' => 'tests\\unit\\testData\\Hooks\\testCase3\\application\\Espo\\Hooks\\Note\\Mentions', 'order' => 9, - ), - ), - ), - ); + ], + ], + ], + ]; - $this->assertEquals($result, $this->reflection->getProperty('data')); + $this->assertEquals($result, $this->reflectionHelper->getProperty('data')); } } From 204134201312b3c3a86d7606af9f094c92120290 Mon Sep 17 00:00:00 2001 From: Yurii Date: Mon, 13 Jul 2026 12:54:52 +0300 Subject: [PATCH 14/87] Ref, cs --- application/Espo/Tools/App/Language/AclDependencyProvider.php | 3 +-- application/Espo/Tools/App/Metadata/AclDependencyProvider.php | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/application/Espo/Tools/App/Language/AclDependencyProvider.php b/application/Espo/Tools/App/Language/AclDependencyProvider.php index 617eb0e0403..b94f2b9c006 100644 --- a/application/Espo/Tools/App/Language/AclDependencyProvider.php +++ b/application/Espo/Tools/App/Language/AclDependencyProvider.php @@ -30,7 +30,6 @@ namespace Espo\Tools\App\Language; use Espo\Core\ORM\Type\FieldType; -use Espo\Core\Utils\Config; use Espo\Core\Utils\Config\SystemConfig; use Espo\Core\Utils\DataCache; use Espo\Core\Utils\Metadata; @@ -38,7 +37,7 @@ class AclDependencyProvider { - private const CACHE_KEY = 'languageAclDependency'; + private const string CACHE_KEY = 'languageAclDependency'; /** @var string[] */ private array $enumFieldTypeList = [ diff --git a/application/Espo/Tools/App/Metadata/AclDependencyProvider.php b/application/Espo/Tools/App/Metadata/AclDependencyProvider.php index eeb20d4c558..4ce03807e89 100644 --- a/application/Espo/Tools/App/Metadata/AclDependencyProvider.php +++ b/application/Espo/Tools/App/Metadata/AclDependencyProvider.php @@ -37,7 +37,7 @@ class AclDependencyProvider { - private const CACHE_KEY = 'metadataAclDependency'; + private const string CACHE_KEY = 'metadataAclDependency'; /** @var string[] */ private array $enumFieldTypeList = [ From 70fde77b11ef8b8d3df0a19f1906e399f14f8dfa Mon Sep 17 00:00:00 2001 From: Yurii Date: Tue, 14 Jul 2026 15:26:45 +0300 Subject: [PATCH 15/87] Additions to security policy --- .github/SECURITY.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/SECURITY.md b/.github/SECURITY.md index 06ce4331679..3f24b743dfc 100644 --- a/.github/SECURITY.md +++ b/.github/SECURITY.md @@ -10,6 +10,10 @@ What reports we do not accept: - Exposing contacts through a target list, campaign or mass email, considering the user has access to them. - SSRF in IMAP/SMTP with TOCTOU. +Submitting multiple unverified reports without a proper proof of concept +(for example, by simply copy-pasting LLM-generated output) may be considered abuse of the reporting process +and may result in the reporting account being blocked. + ## Supported versions For severe vulnerabilities we provide fixes for 2 minor versions (the second number in the version string) back from the current stable version. From e46c29d0d02afcd0521ab00a8f692a9f0d9d7402 Mon Sep 17 00:00:00 2001 From: Yurii Date: Tue, 14 Jul 2026 15:28:40 +0300 Subject: [PATCH 16/87] Fix --- .github/SECURITY.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/SECURITY.md b/.github/SECURITY.md index 3f24b743dfc..4cfcdbd736f 100644 --- a/.github/SECURITY.md +++ b/.github/SECURITY.md @@ -7,7 +7,7 @@ If you believe you have discovered a vulnerability in EspoCRM, please contact us What reports we do not accept: - Executing PHP code by an extension, during extension installation or upgrade process. -- Exposing contacts through a target list, campaign or mass email, considering the user has access to them. +- Exposing contacts through a target list, campaign or mass email features, considering the user has access to these features. - SSRF in IMAP/SMTP with TOCTOU. Submitting multiple unverified reports without a proper proof of concept From 0f94bbeb2693c7dd0c55daf3660ba4b2ef6d2152 Mon Sep 17 00:00:00 2001 From: Yurii Date: Tue, 14 Jul 2026 15:34:27 +0300 Subject: [PATCH 17/87] Cleanup --- .github/SECURITY.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.github/SECURITY.md b/.github/SECURITY.md index 4cfcdbd736f..540be48a1d7 100644 --- a/.github/SECURITY.md +++ b/.github/SECURITY.md @@ -13,7 +13,3 @@ What reports we do not accept: Submitting multiple unverified reports without a proper proof of concept (for example, by simply copy-pasting LLM-generated output) may be considered abuse of the reporting process and may result in the reporting account being blocked. - -## Supported versions - -For severe vulnerabilities we provide fixes for 2 minor versions (the second number in the version string) back from the current stable version. From 3768457dc35d4457b146c75514678c17f11a2139 Mon Sep 17 00:00:00 2001 From: Yurii Date: Thu, 16 Jul 2026 10:35:36 +0300 Subject: [PATCH 18/87] Update state version if core version is lower --- .../Core/Rebuild/Actions/VersionUpdate.php | 63 +++++++++++++++++++ .../Espo/Resources/metadata/app/rebuild.json | 1 + 2 files changed, 64 insertions(+) create mode 100644 application/Espo/Core/Rebuild/Actions/VersionUpdate.php diff --git a/application/Espo/Core/Rebuild/Actions/VersionUpdate.php b/application/Espo/Core/Rebuild/Actions/VersionUpdate.php new file mode 100644 index 00000000000..16f0651a6ac --- /dev/null +++ b/application/Espo/Core/Rebuild/Actions/VersionUpdate.php @@ -0,0 +1,63 @@ +. + * + * 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\Rebuild\Actions; + +use Espo\Core\Rebuild\RebuildAction; +use Espo\Core\Upgrades\Migration\VersionDataProvider; +use Espo\Core\Utils\Config\ConfigWriter; + +/** + * @noinspection PhpUnused + */ +class VersionUpdate implements RebuildAction +{ + private const string DEV_VERSION = '@@version'; + + public function __construct( + private VersionDataProvider $versionDataProvider, + private ConfigWriter $configWriter, + ) {} + + public function process(): void + { + $coreVersion = $this->versionDataProvider->getTargetVersion(); + $stateVersion = $this->versionDataProvider->getPreviousVersion(); + + if ($coreVersion === self::DEV_VERSION || $stateVersion === self::DEV_VERSION) { + return; + } + + if (version_compare($stateVersion, $coreVersion) > 0) { + $this->configWriter->set('version', $coreVersion); + + $this->configWriter->save();; + } + } +} diff --git a/application/Espo/Resources/metadata/app/rebuild.json b/application/Espo/Resources/metadata/app/rebuild.json index 68a7cb23a9b..0e230586b15 100644 --- a/application/Espo/Resources/metadata/app/rebuild.json +++ b/application/Espo/Resources/metadata/app/rebuild.json @@ -2,6 +2,7 @@ "actionClassNameList": [ "Espo\\Core\\Rebuild\\Actions\\AddSystemUser", "Espo\\Core\\Rebuild\\Actions\\AddSystemData", + "Espo\\Core\\Rebuild\\Actions\\VersionUpdate", "Espo\\Core\\Rebuild\\Actions\\ScheduledJobs", "Espo\\Core\\Rebuild\\Actions\\ConfigMetadataCheck", "Espo\\Core\\Rebuild\\Actions\\GenerateInstanceId", From 35e8ab39c6fffc3d512d81f1ac686681ccc8614a Mon Sep 17 00:00:00 2001 From: Yurii Date: Thu, 16 Jul 2026 11:10:30 +0300 Subject: [PATCH 19/87] Skip cron in maintenance mode, cron:run command --- .../Espo/Classes/ConsoleCommands/CronRun.php | 70 +++++++++++++++++++ .../Espo/Core/ApplicationRunners/Cron.php | 8 ++- .../Authentication/ConfigDataProvider.php | 9 ++- .../Espo/Core/Upgrades/Actions/Base.php | 13 +--- .../Espo/Core/Utils/Config/SystemConfig.php | 10 +++ .../metadata/app/consoleCommands.json | 8 +++ 6 files changed, 104 insertions(+), 14 deletions(-) create mode 100644 application/Espo/Classes/ConsoleCommands/CronRun.php diff --git a/application/Espo/Classes/ConsoleCommands/CronRun.php b/application/Espo/Classes/ConsoleCommands/CronRun.php new file mode 100644 index 00000000000..67f51ca51f1 --- /dev/null +++ b/application/Espo/Classes/ConsoleCommands/CronRun.php @@ -0,0 +1,70 @@ +. + * + * 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\Classes\ConsoleCommands; + +use Espo\Core\Console\Command; +use Espo\Core\Console\Command\Params; +use Espo\Core\Console\IO; +use Espo\Core\Job\Exceptions\TooFrequentRun; +use Espo\Core\Job\JobManager; +use Espo\Core\Job\PrepareProcessor; +use Espo\Core\Utils\Config\SystemConfig; +use RuntimeException; + +/** + * @noinspection PhpUnused + */ +class CronRun implements Command +{ + public function __construct( + private PrepareProcessor $prepareProcessor, + private JobManager $jobManager, + private SystemConfig $config, + ) {} + + public function run(Params $params, IO $io): void + { + if (!$this->config->isCronEnabled()) { + throw new RuntimeException("Cron cannot be run as 'cronDisabled' is set to true in the config."); + } + + if (!$params->hasFlag('force') && $this->config->isMaintenanceMode()) { + throw new RuntimeException("Cron cannot be run in maintenance mode. You can use --force flag."); + } + + try { + $this->prepareProcessor->process(); + } catch (TooFrequentRun $e) { + throw new RuntimeException('Too frequent run.', previous: $e); + } + + $this->jobManager->processMainQueue(); + } +} diff --git a/application/Espo/Core/ApplicationRunners/Cron.php b/application/Espo/Core/ApplicationRunners/Cron.php index ce855bb6d94..a074e54c644 100644 --- a/application/Espo/Core/ApplicationRunners/Cron.php +++ b/application/Espo/Core/ApplicationRunners/Cron.php @@ -55,7 +55,13 @@ public function __construct( public function run(): void { if (!$this->config->isCronEnabled()) { - $this->log->warning("Cron is not run because it's disabled with 'cronDisabled' param."); + $this->log->warning("Cron is skipped as 'cronDisabled' is set to true in the config."); + + return; + } + + if ($this->config->isMaintenanceMode()) { + $this->log->warning("Cron run is skipped in maintenance mode."); return; } diff --git a/application/Espo/Core/Authentication/ConfigDataProvider.php b/application/Espo/Core/Authentication/ConfigDataProvider.php index d38960a8176..c3fa8bf68b2 100644 --- a/application/Espo/Core/Authentication/ConfigDataProvider.php +++ b/application/Espo/Core/Authentication/ConfigDataProvider.php @@ -43,8 +43,11 @@ class ConfigDataProvider private const string USERNAME_FAILED_ATTEMPTS_PERIOD = '60 seconds'; private const int USERNAME_FAILED_ATTEMPT_DELAY = 2; - public function __construct(private Config $config, private Metadata $metadata) - {} + public function __construct( + private Config $config, + private Metadata $metadata, + private Config\SystemConfig $systemConfig, + ) {} /** * A period for max failed attempts checking. @@ -106,7 +109,7 @@ public function isAuthTokenSecretDisabled(): bool */ public function isMaintenanceMode(): bool { - return (bool) $this->config->get('maintenanceMode'); + return $this->systemConfig->isMaintenanceMode(); } /** diff --git a/application/Espo/Core/Upgrades/Actions/Base.php b/application/Espo/Core/Upgrades/Actions/Base.php index f84a436469b..5cafa8e67fa 100644 --- a/application/Espo/Core/Upgrades/Actions/Base.php +++ b/application/Espo/Core/Upgrades/Actions/Base.php @@ -83,7 +83,7 @@ abstract class Base public function __construct( private Container $container, - private ActionManager $actionManager + private ActionManager $actionManager, ) { $this->params = $actionManager->getParams(); @@ -1007,9 +1007,8 @@ protected function enableMaintenanceMode(): void } $actualParams = [ - 'maintenanceMode' => $config->get('maintenanceMode'), - 'cronDisabled' => $config->get('cronDisabled'), - 'useCache' => $config->get('useCache'), + 'maintenanceMode' => $this->getSystemConfig()->isMaintenanceMode(), + 'useCache' => $this->getSystemConfig()->useCache(), ]; if ($configParamName) { @@ -1025,12 +1024,6 @@ protected function enableMaintenanceMode(): void $save = true; } - if (!$actualParams['cronDisabled']) { - $configWriter->set('cronDisabled', true); - - $save = true; - } - if ($actualParams['useCache']) { $configWriter->set('useCache', false); diff --git a/application/Espo/Core/Utils/Config/SystemConfig.php b/application/Espo/Core/Utils/Config/SystemConfig.php index d77cd671d0d..f99e4a2fb34 100644 --- a/application/Espo/Core/Utils/Config/SystemConfig.php +++ b/application/Espo/Core/Utils/Config/SystemConfig.php @@ -68,4 +68,14 @@ public function isCronEnabled(): bool { return !$this->config->get('cronDisabled'); } + + /** + * A maintenance mode. Only admin can log in. + * + * @since 10.1.0 + */ + public function isMaintenanceMode(): bool + { + return (bool) $this->config->get('maintenanceMode'); + } } diff --git a/application/Espo/Resources/metadata/app/consoleCommands.json b/application/Espo/Resources/metadata/app/consoleCommands.json index 2558ed1e6e8..c0eee85511a 100644 --- a/application/Espo/Resources/metadata/app/consoleCommands.json +++ b/application/Espo/Resources/metadata/app/consoleCommands.json @@ -107,6 +107,14 @@ "listed": false, "noSystemUser": true }, + "cron:run": { + "className": "Espo\\Classes\\ConsoleCommands\\CronRun", + "listed": true, + "allowedFlags": [ + "force" + ], + "allowedOptions": [] + }, "job:prepare": { "className": "Espo\\Classes\\ConsoleCommands\\JobPrepare", "listed": false, From a70265d702c19bcbc328b2cc5a520a3d1c77cae9 Mon Sep 17 00:00:00 2001 From: Yurii Date: Thu, 16 Jul 2026 11:34:51 +0300 Subject: [PATCH 20/87] Layotu change --- .../Espo/Resources/layouts/Settings/settings.json | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/application/Espo/Resources/layouts/Settings/settings.json b/application/Espo/Resources/layouts/Settings/settings.json index 2decade2036..e80327636d0 100644 --- a/application/Espo/Resources/layouts/Settings/settings.json +++ b/application/Espo/Resources/layouts/Settings/settings.json @@ -8,8 +8,13 @@ }, { "rows": [ - [{"name": "useCache"}, {"name": "useWebSocket"}], - [{"name": "maintenanceMode"}, {"name": "cronDisabled"}] + [{"name": "useCache"}, {"name": "maintenanceMode"}], + [false, {"name": "cronDisabled"}] + ] + }, + { + "rows": [ + [{"name": "useWebSocket"}, false] ] }, { From 70a9c4b2631dc7d4a8b6753e407e0ccecca691d9 Mon Sep 17 00:00:00 2001 From: Yurii Date: Thu, 16 Jul 2026 18:10:59 +0300 Subject: [PATCH 21/87] Data cache access --- .../Espo/Core/Acl/GlobalRestriction.php | 89 +----- application/Espo/Core/Acl/Map/Map.php | 40 +-- .../Core/Currency/InternalRatesProvider.php | 89 +++--- application/Espo/Core/DataManager.php | 2 +- application/Espo/Core/Loaders/Metadata.php | 7 +- .../Espo/Core/Utils/Cache/DataCacheAccess.php | 165 ++++++++++ application/Espo/Core/Utils/Metadata.php | 199 ++++++------ application/Espo/Core/Webhook/Manager.php | 67 ++-- tests/unit/Espo/Core/Acl/Map/MapTest.php | 30 +- tests/unit/Espo/Core/Utils/MetadataTest.php | 291 +++++++++--------- 10 files changed, 508 insertions(+), 471 deletions(-) create mode 100644 application/Espo/Core/Utils/Cache/DataCacheAccess.php diff --git a/application/Espo/Core/Acl/GlobalRestriction.php b/application/Espo/Core/Acl/GlobalRestriction.php index 4a6cc542446..473951a8d98 100644 --- a/application/Espo/Core/Acl/GlobalRestriction.php +++ b/application/Espo/Core/Acl/GlobalRestriction.php @@ -29,8 +29,7 @@ namespace Espo\Core\Acl; -use Espo\Core\Utils\Config\SystemConfig; -use Espo\Core\Utils\DataCache; +use Espo\Core\Utils\Cache\DataCacheAccess; use Espo\Core\Utils\FieldUtil; use Espo\Core\Utils\Metadata; @@ -83,45 +82,21 @@ class GlobalRestriction self::TYPE_READ_ONLY, ]; - private ?stdClass $data = null; - private string $cacheKey = 'entityAcl'; public function __construct( private Metadata $metadata, - private DataCache $dataCache, private FieldUtil $fieldUtil, - SystemConfig $systemConfig, + private DataCacheAccess $dataCacheAccess, ) { - $useCache = $systemConfig->useCache(); - - if ($useCache && $this->dataCache->has($this->cacheKey)) { - /** @var stdClass $cachedData */ - $cachedData = $this->dataCache->get($this->cacheKey); - - $this->data = $cachedData; - - return; - } - - if (!$this->data) { - $this->buildData(); - } - - if ($useCache) { - $this->storeCacheFile(); - } - } - - private function storeCacheFile(): void - { - assert($this->data !== null); - - $this->dataCache->store($this->cacheKey, $this->data); + $this->dataCacheAccess->init( + key: $this->cacheKey, + loader: fn () => $this->buildData(), + ); } - private function buildData(): void + private function buildData(): stdClass { /** @var string[] $scopeList */ $scopeList = array_keys($this->metadata->get(['entityDefs']) ?? []); @@ -211,7 +186,7 @@ private function buildData(): void } } - $this->data = $data; + return $data; } /** @@ -220,21 +195,9 @@ private function buildData(): void */ public function getScopeRestrictedFieldList(string $scope, string $type): array { - assert($this->data !== null); - - if (!property_exists($this->data, $scope)) { - return []; - } + $data = $this->dataCacheAccess->get(); - if (!property_exists($this->data->$scope, 'fields')) { - return []; - } - - if (!property_exists($this->data->$scope->fields, $type)) { - return []; - } - - return $this->data->$scope->fields->$type; + return $data->$scope->fields->$type ?? []; } /** @@ -243,21 +206,9 @@ public function getScopeRestrictedFieldList(string $scope, string $type): array */ public function getScopeRestrictedAttributeList(string $scope, string $type): array { - assert($this->data !== null); - - if (!property_exists($this->data, $scope)) { - return []; - } - - if (!property_exists($this->data->$scope, 'attributes')) { - return []; - } + $data = $this->dataCacheAccess->get(); - if (!property_exists($this->data->$scope->attributes, $type)) { - return []; - } - - return $this->data->$scope->attributes->$type; + return $data->$scope->attributes->$type ?? []; } /** @@ -266,20 +217,8 @@ public function getScopeRestrictedAttributeList(string $scope, string $type): ar */ public function getScopeRestrictedLinkList(string $scope, string $type): array { - assert($this->data !== null); - - if (!property_exists($this->data, $scope)) { - return []; - } - - if (!property_exists($this->data->$scope, 'links')) { - return []; - } - - if (!property_exists($this->data->$scope->links, $type)) { - return []; - } + $data = $this->dataCacheAccess->get(); - return $this->data->$scope->links->$type; + return $data->$scope->links->$type ?? []; } } diff --git a/application/Espo/Core/Acl/Map/Map.php b/application/Espo/Core/Acl/Map/Map.php index 55f5103926c..49f2ab823bd 100644 --- a/application/Espo/Core/Acl/Map/Map.php +++ b/application/Espo/Core/Acl/Map/Map.php @@ -30,10 +30,8 @@ namespace Espo\Core\Acl\Map; use Espo\Core\Acl\Table; -use Espo\Core\Utils\Config; -use Espo\Core\Utils\DataCache; +use Espo\Core\Utils\Cache\DataCacheAccess; use Espo\Core\Utils\ObjectUtil; - use stdClass; use RuntimeException; @@ -42,8 +40,6 @@ */ class Map { - private stdClass $data; - private string $cacheKey; /** @var array */ private $forbiddenFieldsCache = []; /** @var array */ @@ -54,28 +50,20 @@ class Map Table::LEVEL_NO, ]; + /** + * @param DataCacheAccess $dataCacheAccess + */ public function __construct( Table $table, private DataBuilder $dataBuilder, - private DataCache $dataCache, CacheKeyProvider $cacheKeyProvider, - Config\SystemConfig $systemConfig, + private DataCacheAccess $dataCacheAccess, ) { - $this->cacheKey = $cacheKeyProvider->get(); - - if ($systemConfig->useCache() && $this->dataCache->has($this->cacheKey)) { - /** @var stdClass $cachedData */ - $cachedData = $this->dataCache->get($this->cacheKey); - - $this->data = $cachedData; - } else { - $this->data = $this->dataBuilder->build($table); - - if ($systemConfig->useCache()) { - $this->dataCache->store($this->cacheKey, $this->data); - } - } + $this->dataCacheAccess->init( + key: $cacheKeyProvider->get(), + loader: fn () => $this->dataBuilder->build($table), + ); } /** @@ -83,7 +71,7 @@ public function __construct( */ public function getData(): stdClass { - return ObjectUtil::clone($this->data); + return ObjectUtil::clone($this->dataCacheAccess->get()); } /** @@ -114,7 +102,9 @@ public function getScopeForbiddenAttributeList( return $this->forbiddenAttributesCache[$key]; } - $fieldTableQuickAccess = $this->data->fieldTableQuickAccess; + $data = $this->dataCacheAccess->get(); + + $fieldTableQuickAccess = $data->fieldTableQuickAccess; if ( !isset($fieldTableQuickAccess->$scope) || @@ -186,7 +176,9 @@ public function getScopeForbiddenFieldList( return $this->forbiddenFieldsCache[$key]; } - $fieldTableQuickAccess = $this->data->fieldTableQuickAccess; + $data = $this->dataCacheAccess->get(); + + $fieldTableQuickAccess = $data->fieldTableQuickAccess; if ( !isset($fieldTableQuickAccess->$scope) || diff --git a/application/Espo/Core/Currency/InternalRatesProvider.php b/application/Espo/Core/Currency/InternalRatesProvider.php index e52a2cd39e9..efaa58ee66b 100644 --- a/application/Espo/Core/Currency/InternalRatesProvider.php +++ b/application/Espo/Core/Currency/InternalRatesProvider.php @@ -30,10 +30,10 @@ namespace Espo\Core\Currency; use Espo\Core\Field\Date; -use Espo\Core\Utils\Config\SystemConfig; -use Espo\Core\Utils\DataCache; +use Espo\Core\Utils\Cache\DataCacheAccess; use Espo\Core\Utils\DateTime; use LogicException; +use RuntimeException; use stdClass; /** @@ -43,74 +43,55 @@ class InternalRatesProvider { private string $cacheKey = 'currencyRates'; - /** @var (stdClass&object{date: string, rates: stdClass})|null */ - private ?stdClass $data = null; + private ?Date $today = null; + private ?string $base = null; + /** + * @param DataCacheAccess $dataCacheAccess + */ public function __construct( - private DataCache $dataCache, - private SystemConfig $systemConfig, private DateTime $dateTime, private InternalRateEntryProvider $rateEntryProvider, - ) {} + private DataCacheAccess $dataCacheAccess, + ) { + $this->dataCacheAccess->init( + key: $this->cacheKey, + loader: function () { + if (!$this->today || $this->base === null) { + throw new LogicException(); + } + + return $this->buildData($this->today, $this->base); + }, + validityChecker: function (stdClass $data): bool { + if (!$this->today) { + throw new LogicException(); + } + + $date = $data->date ?? null; + + return $date === $this->today->toString(); + }, + ); + } /** * @return array */ public function get(string $base): array { - $this->data ??= $this->getCachedData(); - $today = $this->dateTime->getToday(); - if (!$this->data || $this->data->date !== $today->toString()) { - $this->data = $this->buildData($today, $base); + $this->today = $today; + $this->base = $base; - $this->storeData(); - } - - if ($this->data === null) { - throw new LogicException(); - } - - return get_object_vars($this->data->rates); - } - - /** - * @return (stdClass&object{date: string, rates: stdClass})|null - */ - private function getCachedData(): ?stdClass - { - if (!$this->systemConfig->useCache()) { - return null; - } - - $cached = $this->dataCache->tryGet($this->cacheKey); - - if (!$cached instanceof stdClass) { - return null; - } - - if (!isset($cached->date) || !isset($cached->rates)) { - $this->dataCache->clear($this->cacheKey); - - return null; - } - - /** @var stdClass&object{date: string, rates: stdClass} */ - return $cached; - } - - private function storeData(): void - { - if (!$this->systemConfig->useCache()) { - return; - } + $data = $this->dataCacheAccess->get(); - if (!$this->data instanceof stdClass) { - throw new LogicException(); + if (!property_exists($data, 'rates')) { + throw new RuntimeException("Corrupted cache data in '$this->cacheKey'."); } - $this->dataCache->store($this->cacheKey, $this->data); + return get_object_vars($data->rates); } /** diff --git a/application/Espo/Core/DataManager.php b/application/Espo/Core/DataManager.php index 802cc02ed44..2361ead3ff5 100644 --- a/application/Espo/Core/DataManager.php +++ b/application/Espo/Core/DataManager.php @@ -169,7 +169,7 @@ public function rebuildDatabase(?array $entityTypeList = null, string $mode = Re */ public function rebuildMetadata(): void { - $this->metadata->init(true); + $this->metadata->init(); $this->ormMetadataData->reload(); $this->entityManager->getMetadata()->updateData(); diff --git a/application/Espo/Core/Loaders/Metadata.php b/application/Espo/Core/Loaders/Metadata.php index bbc8653f0ae..0cf435b513b 100644 --- a/application/Espo/Core/Loaders/Metadata.php +++ b/application/Espo/Core/Loaders/Metadata.php @@ -31,18 +31,15 @@ use Espo\Core\Container\Loader; use Espo\Core\InjectableFactory; -use Espo\Core\Utils\Config; use Espo\Core\Utils\Metadata as MetadataService; class Metadata implements Loader { - public function __construct(private InjectableFactory $injectableFactory, private Config $config) + public function __construct(private InjectableFactory $injectableFactory) {} public function load(): MetadataService { - return $this->injectableFactory->createWith(MetadataService::class, [ - 'useCache' => $this->config->get('useCache') ?? false, - ]); + return $this->injectableFactory->create(MetadataService::class); } } diff --git a/application/Espo/Core/Utils/Cache/DataCacheAccess.php b/application/Espo/Core/Utils/Cache/DataCacheAccess.php new file mode 100644 index 00000000000..ac5588743c2 --- /dev/null +++ b/application/Espo/Core/Utils/Cache/DataCacheAccess.php @@ -0,0 +1,165 @@ +. + * + * 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\Cache; + +use Closure; +use Espo\Core\Utils\Config\SystemConfig; +use Espo\Core\Utils\DataCache; +use Espo\Core\Utils\Log; +use LogicException; +use stdClass; + +/** + * @internal + * @since 10.1.0 + * @template T of array | stdClass = array | stdClass + */ +class DataCacheAccess +{ + /** @var T|null */ + private mixed $data = null; + + private ?string $key = null; + + /** @var (Closure(): T)|null */ + private ?Closure $loader = null; + + /** @var (Closure(T): bool)|null */ + private ?Closure $validityChecker = null; + + public function __construct( + private DataCache $dataCache, + private SystemConfig $systemConfig, + private Log $log, + ) {} + + /** + * @todo Event clearing loaded data. + * + * @param Closure(): T $loader + * @param (Closure(T): bool)|null $validityChecker + */ + public function init(string $key, Closure $loader, ?Closure $validityChecker = null): void + { + $this->key = $key; + $this->loader = $loader; + $this->validityChecker = $validityChecker; + + $this->data = null; + } + + public function reset(): void + { + $this->data = null; + } + + /** + * @param T $data + */ + public function set(mixed $data): void + { + $this->data = $data; + } + + public function store(): void + { + if (!$this->key) { + throw new LogicException("Not initialized."); + } + + if ($this->data === null) { + throw new LogicException("Data not set."); + } + + if ($this->systemConfig->useCache()) { + $this->dataCache->store($this->key, $this->data); + } + } + + /** + * @return T + */ + public function get(): mixed + { + if ($this->data && $this->validityChecker && !($this->validityChecker)($this->data)) { + $this->data = null; + } + + if ($this->data !== null) { + return $this->data; + } + + $key = $this->key; + $loader = $this->loader; + + if (!$key || !$loader) { + throw new LogicException("Not initialized."); + } + + if ($this->systemConfig->useCache() && $this->dataCache->has($key)) { + $this->loadFromCache(); + } + + if ($this->data === null) { + $this->data = $loader(); + + if ($this->systemConfig->useCache()) { + $this->dataCache->store($key, $this->data); + } + } + + return $this->data; + } + + private function loadFromCache(): void + { + $key = $this->key ?? throw new LogicException(); + + $data = $this->dataCache->tryGet($key); + + if (is_array($data) || $data instanceof stdClass) { + /** @var T $data */ + + if ($this->validityChecker && !($this->validityChecker)($data)) { + $this->data = null; + + return; + } + + $this->data = $data; + + return; + } + + $this->log->warning("Corrupted cache data in '$key'."); + + $this->dataCache->clear($key); + } +} diff --git a/application/Espo/Core/Utils/Metadata.php b/application/Espo/Core/Utils/Metadata.php index a906ca1a6fb..96191c963f8 100644 --- a/application/Espo/Core/Utils/Metadata.php +++ b/application/Espo/Core/Utils/Metadata.php @@ -29,10 +29,10 @@ namespace Espo\Core\Utils; +use Espo\Core\Utils\Cache\DataCacheAccess; use Espo\Core\Utils\File\Manager as FileManager; use Espo\Core\Utils\Metadata\Builder; use Espo\Core\Utils\Metadata\BuilderHelper; - use stdClass; use LogicException; use RuntimeException; @@ -42,55 +42,49 @@ */ class Metadata { - /** @var ?array */ - private ?array $data = null; - private ?stdClass $objData = null; - private string $cacheKey = 'metadata'; private string $objCacheKey = 'objMetadata'; private string $customPath = 'custom/Espo/Custom/Resources/metadata'; /** @var array> */ private $deletedData = []; + /** @var array> */ private $changedData = []; + /** + * @param DataCacheAccess> $data + * @param DataCacheAccess $objectData + */ public function __construct( private FileManager $fileManager, - private DataCache $dataCache, private Module $module, private Builder $builder, private BuilderHelper $builderHelper, - private bool $useCache = false - ) {} + private DataCacheAccess $data, + private DataCacheAccess $objectData, + ) { + + $this->objectData->init( + key: $this->objCacheKey, + loader: fn () => $this->builder->build(), + ); + + $this->data->init( + key: $this->cacheKey, + loader: fn () => $this->getObjectConvertedToAssoc(), + ); + } /** * Init metadata. + * + * @internal */ - public function init(bool $reload = false): void + public function init(): void { - if (!$this->useCache) { - $reload = true; - } - - if ($this->dataCache->has($this->cacheKey) && !$reload) { - /** @var array $data */ - $data = $this->dataCache->get($this->cacheKey); - - $this->data = $data; - - return; - } - - $this->clearVars(); - - $objData = $this->getObjData($reload); - - $this->data = Util::objectToArray($objData); - - if ($this->useCache) { - $this->dataCache->store($this->cacheKey, $this->data); - } + $this->reloadObject(); + $this->reload(); } /** @@ -100,13 +94,7 @@ public function init(bool $reload = false): void */ private function getData(): array { - if (empty($this->data) || !is_array($this->data)) { - $this->init(); - } - - assert($this->data !== null); - - return $this->data; + return $this->data->get(); } /** @@ -118,40 +106,9 @@ private function getData(): array */ public function get($key = null, $default = null) { - return Util::getValueByKey($this->getData(), $key, $default); - } - - private function objInit(bool $reload = false): void - { - if (!$this->useCache) { - $reload = true; - } - - if ($this->dataCache->has($this->objCacheKey) && !$reload) { - /** @var stdClass $data */ - $data = $this->dataCache->get($this->objCacheKey); - - $this->objData = $data; - - return; - } - - $this->objData = $this->builder->build(); - - if ($this->useCache) { - $this->dataCache->store($this->objCacheKey, $this->objData); - } - } - - private function getObjData(bool $reload = false): stdClass - { - if (!isset($this->objData) || $reload) { - $this->objInit($reload); - } + $data = $this->data->get(); - assert($this->objData !== null); - - return $this->objData; + return Util::getValueByKey($data, $key, $default); } /** @@ -163,18 +120,17 @@ private function getObjData(bool $reload = false): stdClass */ public function getObjects($key = null, $default = null) { - $objData = $this->getObjData(); - - return Util::getValueByKey($objData, $key, $default); + return Util::getValueByKey($this->getAll(), $key, $default); } + /** + * Important. Do not modify without cloning. + */ public function getAll(): stdClass { - return $this->getObjData(); + return $this->objectData->get(); } - - /** * Get metadata definition in custom directory. * @@ -183,7 +139,7 @@ public function getAll(): stdClass */ public function getCustom(string $key1, string $key2, $default = null) { - $filePath = $this->customPath . "/$key1/$key2.json"; + $filePath = "$this->customPath/$key1/$key2.json"; if (!$this->fileManager->isFile($filePath)) { return $default; @@ -213,13 +169,13 @@ public function saveCustom(string $key1, string $key2, $data): void } } - $filePath = $this->customPath . "/$key1/$key2.json"; + $filePath = "$this->customPath/$key1/$key2.json"; $changedData = Json::encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); $this->fileManager->putContents($filePath, $changedData); - $this->init(true); + $this->init(); } /** @@ -264,11 +220,13 @@ private function setInternal(string $key1, string $key2, $data, bool $allowEmpty /** @var array> $mergedChangedData */ $mergedChangedData = Util::merge($this->changedData, $newData); + /** @var array $mergedData */ $mergedData = Util::merge($this->getData(), $newData); $this->changedData = $mergedChangedData; - $this->data = $mergedData; + + $this->data->set($mergedData); if (is_array($data)) { $this->undelete($key1, $key2, $data); @@ -294,24 +252,27 @@ public function delete(string $key1, string $key2, $unsets = null): void $unsetList = $unsets; foreach ($unsetList as $unsetItem) { - if (preg_match('/fields\.([^.]+)/', $unsetItem, $matches)) { - $field = $matches[1]; - $fieldPath = [$key1, $key2, 'fields', $field]; - - // @todo Revise the need. Additional fields are supposed to exist only in the build? - $additionalFields = $this->builderHelper->getAdditionalFields( - field: $field, - params: $this->get($fieldPath, []), - defs: $defs, - ); + if (!preg_match('/fields\.([^.]+)/', $unsetItem, $matches)) { + continue; + } + + $field = $matches[1]; + $fieldPath = [$key1, $key2, 'fields', $field]; - if (is_array($additionalFields)) { - foreach ($additionalFields as $additionalFieldName => $additionalFieldParams) { - $unsets[] = 'fields.' . $additionalFieldName; - } + // @todo Revise the need. Additional fields are supposed to exist only in the build? + $additionalFields = $this->builderHelper->getAdditionalFields( + field: $field, + params: $this->get($fieldPath, []), + defs: $defs, + ); + + if (is_array($additionalFields)) { + foreach ($additionalFields as $additionalFieldName => $additionalFieldParams) { + $unsets[] = 'fields.' . $additionalFieldName; } } } + break; } @@ -343,7 +304,8 @@ public function delete(string $key1, string $key2, $unsets = null): void /** @var array $data */ $data = Util::unsetInArray($this->getData(), $metadataUnsetData, true); - $this->data = $data; + + $this->data->set($data); } /** @@ -351,13 +313,15 @@ public function delete(string $key1, string $key2, $unsets = null): void */ private function undelete(string $key1, string $key2, $data): void { - if (isset($this->deletedData[$key1][$key2])) { - foreach ($this->deletedData[$key1][$key2] as $unsetIndex => $unsetItem) { - $value = Util::getValueByKey($data, $unsetItem); + if (!isset($this->deletedData[$key1][$key2])) { + return; + } - if (isset($value)) { - unset($this->deletedData[$key1][$key2][$unsetIndex]); - } + foreach ($this->deletedData[$key1][$key2] as $unsetIndex => $unsetItem) { + $value = Util::getValueByKey($data, $unsetItem); + + if (isset($value)) { + unset($this->deletedData[$key1][$key2][$unsetIndex]); } } } @@ -370,7 +334,7 @@ public function clearChanges(): void $this->changedData = []; $this->deletedData = []; - $this->init(true); + $this->init(); } /** @@ -389,7 +353,7 @@ public function save(): bool continue; } - $filePath = $path . "/$key1/$key2.json"; + $filePath = "$path/$key1/$key2.json"; $result &= $this->fileManager->mergeJsonContents($filePath, $data); } @@ -403,7 +367,7 @@ public function save(): bool continue; } - $filePath = $path . "/$key1/$key2.json"; + $filePath = "$path/$key1/$key2.json"; $rowResult = $this->fileManager->unsetJsonContents($filePath, $unsetData); @@ -443,8 +407,29 @@ public function getScopeModuleName(string $scopeName): ?string return $this->get(['scopes', $scopeName, 'module']); } - private function clearVars(): void + private function reloadObject(): void + { + $data = $this->builder->build(); + + $this->objectData->set($data); + $this->objectData->store(); + } + + private function reload(): void { - $this->data = null; + $data = $this->getObjectConvertedToAssoc(); + + $this->data->set($data); + $this->data->store(); + } + + /** + * @return array + */ + private function getObjectConvertedToAssoc(): array + { + $data = $this->objectData->get(); + + return Util::objectToArray($data); } } diff --git a/application/Espo/Core/Webhook/Manager.php b/application/Espo/Core/Webhook/Manager.php index cbe01671334..d78e32ea996 100644 --- a/application/Espo/Core/Webhook/Manager.php +++ b/application/Espo/Core/Webhook/Manager.php @@ -32,16 +32,13 @@ use Espo\Core\Name\Field; use Espo\Core\ORM\Entity; use Espo\Core\ORM\EntityManager; +use Espo\Core\Utils\Cache\DataCacheAccess; use Espo\Core\Utils\Config; -use Espo\Core\Utils\Config\SystemConfig; -use Espo\Core\Utils\DataCache; use Espo\Core\Utils\FieldUtil; use Espo\Core\Utils\Log; use Espo\Entities\Webhook; use Espo\Entities\WebhookEventQueueItem; - use Espo\ORM\Name\Attribute; -use RuntimeException; use stdClass; /** @@ -63,45 +60,21 @@ class Manager Field::VERSION_NUMBER, ]; - /** @var ?array */ - private $data = null; - + /** + * @param DataCacheAccess> $dataCacheAccess + */ public function __construct( private Config $config, - private DataCache $dataCache, private EntityManager $entityManager, private FieldUtil $fieldUtil, private Log $log, - private SystemConfig $systemConfig, + private DataCacheAccess $dataCacheAccess, ) { - $this->loadData(); - } - - private function loadData(): void - { - if ($this->systemConfig->useCache() && $this->dataCache->has($this->cacheKey)) { - /** @var array $data */ - $data = $this->dataCache->get($this->cacheKey); - - $this->data = $data; - } - - if (is_null($this->data)) { - $this->data = $this->buildData(); - - if ($this->systemConfig->useCache()) { - $this->storeDataToCache(); - } - } - } - - private function storeDataToCache(): void - { - if ($this->data === null) { - throw new RuntimeException("No data to store."); - } - $this->dataCache->store($this->cacheKey, $this->data); + $this->dataCacheAccess->init( + key: $this->cacheKey, + loader: fn () => $this->buildData(), + ); } /** @@ -135,11 +108,12 @@ private function buildData(): array */ public function addEvent(string $event): void { - $this->data[$event] = true; + $data = $this->dataCacheAccess->get(); - if ($this->systemConfig->useCache()) { - $this->storeDataToCache(); - } + $data[$event] = true; + + $this->dataCacheAccess->set($data); + $this->dataCacheAccess->store(); } /** @@ -160,16 +134,19 @@ public function removeEvent(string $event): void return; } - unset($this->data[$event]); + $data = $this->dataCacheAccess->get(); - if ($this->systemConfig->useCache()) { - $this->storeDataToCache(); - } + unset($data[$event]); + + $this->dataCacheAccess->set($data); + $this->dataCacheAccess->store(); } private function eventExists(string $event): bool { - return isset($this->data[$event]); + $data = $this->dataCacheAccess->get(); + + return isset($data[$event]); } private function logDebugEvent(string $event, Entity $entity): void diff --git a/tests/unit/Espo/Core/Acl/Map/MapTest.php b/tests/unit/Espo/Core/Acl/Map/MapTest.php index 694df5d25b2..40d35c3c4c9 100644 --- a/tests/unit/Espo/Core/Acl/Map/MapTest.php +++ b/tests/unit/Espo/Core/Acl/Map/MapTest.php @@ -36,35 +36,29 @@ use Espo\Core\Acl\Map\MetadataProvider; use Espo\Core\Acl\ScopeData; use Espo\Core\Acl\Table; -use Espo\Core\Utils\Config; +use Espo\Core\Utils\Cache\DataCacheAccess; +use Espo\Core\Utils\Config\SystemConfig; use Espo\Core\Utils\DataCache; use Espo\Core\Utils\FieldUtil; +use Espo\Core\Utils\Log; use PHPUnit\Framework\TestCase; use stdClass; class MapTest extends TestCase { private $fieldUtil; - private $systemConfig; private $table; private $metadataProvider; private $cacheKeyProvider; - private $dataCache; protected function setUp(): void { - $this->systemConfig = $this->createMock(Config\SystemConfig::class); $this->fieldUtil = $this->createMock(FieldUtil::class); $this->table = $this->createMock(Table::class); - $this->dataCache = $this->createMock(DataCache::class); $this->metadataProvider = $this->createMock(MetadataProvider::class); $this->cacheKeyProvider = $this->createMock(CacheKeyProvider::class); - $this->systemConfig - ->expects($this->any()) - ->method('useCache') - ->willReturn(false); } private function mockTableData(array $scopeData, array $fieldData, array $permissionData): void @@ -201,12 +195,20 @@ public function testMap1(): void $expectedData = $this->getExpectedRawData(); + $this->cacheKeyProvider->method('get') + ->willReturn('key'); + + $dataCacheAccess = new DataCacheAccess( + dataCache: $this->createMock(DataCache::class), + systemConfig: $this->createMock(SystemConfig::class), + log: $this->createMock(Log::class), + ); + $map = new Map( - $this->table, - $dataBuilder, - $this->dataCache, - $this->cacheKeyProvider, - $this->systemConfig, + table: $this->table, + dataBuilder: $dataBuilder, + cacheKeyProvider: $this->cacheKeyProvider, + dataCacheAccess: $dataCacheAccess, ); $this->assertEquals($expectedData, $map->getData()); diff --git a/tests/unit/Espo/Core/Utils/MetadataTest.php b/tests/unit/Espo/Core/Utils/MetadataTest.php index 6bd532f16cb..75265ebcabd 100644 --- a/tests/unit/Espo/Core/Utils/MetadataTest.php +++ b/tests/unit/Espo/Core/Utils/MetadataTest.php @@ -29,13 +29,15 @@ namespace tests\unit\Espo\Core\Utils; +use Espo\Core\Utils\Cache\DataCacheAccess; +use Espo\Core\Utils\Config\SystemConfig; +use Espo\Core\Utils\DataCache; +use Espo\Core\Utils\Json; use PHPUnit\Framework\TestCase; use tests\unit\ReflectionHelper; - use Espo\Core\Utils\Metadata; use Espo\Core\Utils\File\Manager as FileManager; use Espo\Core\Utils\Log; -use Espo\Core\Utils\DataCache; use Espo\Core\Utils\File\UnifierObj; use Espo\Core\Utils\File\Unifier; use Espo\Core\Utils\Module; @@ -45,21 +47,16 @@ class MetadataTest extends TestCase { - private $object; + private ?Metadata $metadata = null; private $reflection; - private $fileManager; + private ?FileManager $fileManager = null; + private $customPath; protected function setUp(): void { $this->fileManager = new FileManager(); - $dataCache = $this->getMockBuilder(DataCache::class)->disableOriginalConstructor()->getMock(); - - $log = $this->getMockBuilder(Log::class)->disableOriginalConstructor()->getMock(); - - $GLOBALS['log'] = $log; - $module = new Module($this->fileManager); $pathProvider = new PathProvider(new ModulePathProvider($module)); @@ -73,16 +70,28 @@ protected function setUp(): void $builder = new Metadata\Builder($reader); - $this->object = new Metadata( - $this->fileManager, - $dataCache, - $module, - $builder, - $builderHelper, - true + $dataCacheAccess = new DataCacheAccess( + dataCache: $this->createMock(DataCache::class), + systemConfig: $this->createMock(SystemConfig::class), + log: $this->createMock(Log::class), ); - $this->reflection = new ReflectionHelper($this->object); + $objectDataCacheAccess = new DataCacheAccess( + dataCache: $this->createMock(DataCache::class), + systemConfig: $this->createMock(SystemConfig::class), + log: $this->createMock(Log::class), + ); + + $this->metadata = new Metadata( + fileManager: $this->fileManager, + module: $module, + builder: $builder, + builderHelper: $builderHelper, + data: $dataCacheAccess, + objectData: $objectDataCacheAccess, + ); + + $this->reflection = new ReflectionHelper($this->metadata); $this->customPath = 'tests/unit/testData/cache/metadata/custom'; @@ -91,180 +100,170 @@ protected function setUp(): void protected function tearDown() : void { - $this->object->clearChanges(); - $this->object = NULL; + $this->metadata->clearChanges(); + $this->metadata = null; } - public function testGet() + public function testGet(): void { - $this->assertEquals('System', $this->object->get('app.adminPanel.system.label')); + $this->assertEquals('System', $this->metadata->get('app.adminPanel.system.label')); - $this->assertArrayHasKey('fields', $this->object->get('entityDefs.User')); + $this->assertArrayHasKey('fields', $this->metadata->get('entityDefs.User')); } - public function testSet() + public function testSet(): void { - $data = array ( + $data = [ 'fields' => - array ( + [ 'name' => - array ( + [ 'required' => false, 'maxLength' => 150, 'view' => 'Views.Test.Custom', - ), - ), - ); + ], + ], + ]; - $this->object->set('entityDefs', 'Attachment', $data); + $this->metadata->set('entityDefs', 'Attachment', $data); - $this->assertEquals('Views.Test.Custom', $this->object->get('entityDefs.Attachment.fields.name.view')); - $this->assertEquals(150, $this->object->get('entityDefs.Attachment.fields.name.maxLength')); + $this->assertEquals('Views.Test.Custom', $this->metadata->get('entityDefs.Attachment.fields.name.view')); + $this->assertEquals(150, $this->metadata->get('entityDefs.Attachment.fields.name.maxLength')); - $result = array( - 'entityDefs' => array( + $result = [ + 'entityDefs' => [ 'Attachment' => $data - ), - ); + ], + ]; $this->assertEquals($result, $this->reflection->getProperty('changedData')); - $data = array ( + $data = [ 'fields' => - array ( + [ 'name' => - array ( + [ 'maxLength' => 200, - ), - ), - ); + ], + ], + ]; - $this->object->set('entityDefs', 'Attachment', $data); - $this->assertEquals(200, $this->object->get('entityDefs.Attachment.fields.name.maxLength')); - $this->assertEquals('Views.Test.Custom', $this->object->get('entityDefs.Attachment.fields.name.view')); + $this->metadata->set('entityDefs', 'Attachment', $data); + $this->assertEquals(200, $this->metadata->get('entityDefs.Attachment.fields.name.maxLength')); + $this->assertEquals('Views.Test.Custom', $this->metadata->get('entityDefs.Attachment.fields.name.view')); - $result = array( - 'entityDefs' => array( - 'Attachment' => array ( + $result = [ + 'entityDefs' => [ + 'Attachment' => [ 'fields' => - array ( + [ 'name' => - array ( + [ 'required' => false, 'maxLength' => 200, 'view' => 'Views.Test.Custom', - ), - ), - ), - ), - ); + ], + ], + ], + ], + ]; $this->assertEquals($result, $this->reflection->getProperty('changedData')); - $this->object->clearChanges(); + $this->metadata->clearChanges(); - $this->assertEquals(array(), $this->reflection->getProperty('changedData')); - $this->assertEquals(255, $this->object->get('entityDefs.Attachment.fields.name.maxLength')); + $this->assertEquals([], $this->reflection->getProperty('changedData')); + $this->assertEquals(255, $this->metadata->get('entityDefs.Attachment.fields.name.maxLength')); } - public function testDelete() + public function testDelete(): void { - $data = array ( + $this->metadata->delete('entityDefs', 'Attachment', [ 'fields.name.type', - ); - $this->object->delete('entityDefs', 'Attachment', $data); - $this->assertNull($this->object->get('entityDefs.Attachment.fields.name.type')); + ]); - $result = array( - 'entityDefs' => array( - 'Attachment' => array( - 'fields.name.type', - ), - ), - ); + $this->assertNull($this->metadata->get('entityDefs.Attachment.fields.name.type')); - $this->assertEquals($result, $this->reflection->getProperty('deletedData')); + $this->assertEquals([ + 'entityDefs' => [ + 'Attachment' => [ + 'fields.name.type', + ], + ], + ], $this->reflection->getProperty('deletedData')); - $data = array ( + $this->metadata->delete('entityDefs', 'Attachment', [ 'fields.name.required', - ); - $this->object->delete('entityDefs', 'Attachment', $data); - $this->assertNull($this->object->get('entityDefs.Attachment.fields.name.required')); + ]); + + $this->assertNull($this->metadata->get('entityDefs.Attachment.fields.name.required')); - $result = array( - 'entityDefs' => array( - 'Attachment' => array( + $this->assertEquals([ + 'entityDefs' => [ + 'Attachment' => [ 'fields.name.type', 'fields.name.required', - ), - ), - ); - $this->assertEquals($result, $this->reflection->getProperty('deletedData')); + ], + ], + ], $this->reflection->getProperty('deletedData')); - $this->object->init(false); + $this->metadata->init(); - $this->assertNotNull($this->object->get('entityDefs.Attachment.fields.name.type')); - $this->assertNotNull($this->object->get('entityDefs.Attachment.fields.name.required')); + $this->assertNotNull($this->metadata->get('entityDefs.Attachment.fields.name.type')); + $this->assertNotNull($this->metadata->get('entityDefs.Attachment.fields.name.required')); - $this->object->clearChanges(); + $this->metadata->clearChanges(); $this->assertEquals([], $this->reflection->getProperty('deletedData')); } - public function testUndelete() + public function testUndelete(): void { - $data = [ + $this->metadata->delete('entityDefs', 'Attachment', [ 'fields.name.type', 'fields.name.required', - ]; + ]); - $this->object->delete('entityDefs', 'Attachment', $data); - $this->assertNull($this->object->get('entityDefs.Attachment.fields.name.type')); + $this->assertNull($this->metadata->get('entityDefs.Attachment.fields.name.type')); - $data = array ( - 'fields' => - array ( - 'name' => - array ( - 'type' => 'enum', - ), - ), - ); - $this->object->set('entityDefs', 'Attachment', $data); - $this->assertEquals('enum', $this->object->get('entityDefs.Attachment.fields.name.type')); - $result = array( - 'entityDefs' => array( - 'Attachment' => array( + $this->metadata->set('entityDefs', 'Attachment', [ + 'fields' => [ + 'name' => [ + 'type' => 'enum', + ], + ], + ]); + + $this->assertEquals('enum', $this->metadata->get('entityDefs.Attachment.fields.name.type')); + + $this->assertEquals([ + 'entityDefs' => [ + 'Attachment' => [ 1 => 'fields.name.required', - ), - ), - ); - $this->assertEquals($result, $this->reflection->getProperty('deletedData')); + ], + ], + ], $this->reflection->getProperty('deletedData')); - $data = array ( - 'fields' => - array ( - 'name' => - array ( - 'required' => true, - ), - ), - ); - $this->object->set('entityDefs', 'Attachment', $data); - $this->assertEquals(true, $this->object->get('entityDefs.Attachment.fields.name.required')); - - $result = array( - 'entityDefs' => array( - 'Attachment' => array( - ), - ), - ); - $this->assertEquals($result, $this->reflection->getProperty('deletedData')); + $this->metadata->set('entityDefs', 'Attachment', [ + 'fields' => [ + 'name' => [ + 'required' => true, + ], + ], + ]); + + $this->assertEquals(true, $this->metadata->get('entityDefs.Attachment.fields.name.required')); + + $this->assertEquals([ + 'entityDefs' => [ + 'Attachment' => [], + ], + ], $this->reflection->getProperty('deletedData')); } - public function testGetCustom() + public function testGetCustom(): void { - $this->assertNull($this->object->getCustom('entityDefs', 'Lead')); + $this->assertNull($this->metadata->getCustom('entityDefs', 'Lead')); - $customData = $this->object->getCustom('entityDefs', 'Lead', (object) []); + $customData = $this->metadata->getCustom('entityDefs', 'Lead', (object) []); $this->assertTrue(is_object($customData)); @@ -277,14 +276,14 @@ public function testGetCustom() ], ]; - $this->object->saveCustom('entityDefs', 'Lead', $data); + $this->metadata->saveCustom('entityDefs', 'Lead', $data); - $this->assertEquals($data, $this->object->getCustom('entityDefs', 'Lead')); + $this->assertEquals($data, $this->metadata->getCustom('entityDefs', 'Lead')); unlink($this->customPath . '/entityDefs/Lead.json'); } - public function testSaveCustom1() + public function testSaveCustom1(): void { $data = (object) [ 'fields' => (object) [ @@ -295,19 +294,19 @@ public function testSaveCustom1() ], ]; - $this->object->saveCustom('entityDefs', 'Lead', $data); + $this->metadata->saveCustom('entityDefs', 'Lead', $data); $savedFile = $this->customPath . '/entityDefs/Lead.json'; $fileContent = $this->fileManager->getContents($savedFile); - $savedData = \Espo\Core\Utils\Json::decode($fileContent); + $savedData = Json::decode($fileContent); $this->assertEquals($data, $savedData); unlink($savedFile); } - public function testSaveCustom2() + public function testSaveCustom2(): void { $initData = (object) [ 'fields' => (object) [ @@ -318,19 +317,19 @@ public function testSaveCustom2() ], ]; - $this->object->saveCustom('entityDefs', 'Lead', $initData); + $this->metadata->saveCustom('entityDefs', 'Lead', $initData); - $customData = $this->object->getCustom('entityDefs', 'Lead'); + $customData = $this->metadata->getCustom('entityDefs', 'Lead'); unset($customData->fields->status->type); $customData->fields->status->options = ["__APPEND__", "Test1"]; - $this->object->saveCustom('entityDefs', 'Lead', $customData); + $this->metadata->saveCustom('entityDefs', 'Lead', $customData); $savedFile = $this->customPath . '/entityDefs/Lead.json'; $fileContent = $this->fileManager->getContents($savedFile); - $savedData = \Espo\Core\Utils\Json::decode($fileContent); + $savedData = Json::decode($fileContent); $expectedData = (object) [ 'fields' => (object) [ @@ -345,10 +344,10 @@ public function testSaveCustom2() unlink($savedFile); } - public function testGetObjects() + public function testGetObjects(): void { - $this->assertEquals('System', $this->object->getObjects('app.adminPanel.system.label')); - $this->assertObjectHasProperty('fields', $this->object->getObjects('entityDefs.User')); - $this->assertObjectHasProperty('type', $this->object->getObjects('entityDefs.User.fields.name')); + $this->assertEquals('System', $this->metadata->getObjects('app.adminPanel.system.label')); + $this->assertObjectHasProperty('fields', $this->metadata->getObjects('entityDefs.User')); + $this->assertObjectHasProperty('type', $this->metadata->getObjects('entityDefs.User.fields.name')); } } From 542cc602e630b967daa09d48b784974108bca58b Mon Sep 17 00:00:00 2001 From: Yurii Date: Thu, 16 Jul 2026 13:24:47 +0300 Subject: [PATCH 22/87] System state --- application/Espo/Binding.php | 5 + application/Espo/Core/Acl/Cache/Clearer.php | 21 +++- application/Espo/Core/Loaders/SystemState.php | 49 ++++++++ .../Espo/Core/ORM/EntityManagerProxy.php | 11 ++ .../Core/Rebuild/Actions/AddSystemState.php | 53 +++++++++ .../Espo/Core/Utils/EmailFilterManager.php | 31 ++++- .../Espo/Core/Utils/System/SystemState.php | 70 ++++++++++++ .../Core/Utils/User/UserStateProvider.php | 106 ++++++++++++++++++ application/Espo/Entities/SystemState.php | 50 +++++++++ application/Espo/Entities/UserState.php | 50 +++++++++ .../Espo/Hooks/EmailFilter/CacheClearing.php | 10 +- .../Espo/Hooks/User/DeleteUserState.php | 60 ++++++++++ .../Espo/Resources/metadata/app/rebuild.json | 1 + .../metadata/entityAcl/SystemState.json | 3 + .../metadata/entityAcl/UserState.json | 3 + .../metadata/entityDefs/SystemState.json | 15 +++ .../metadata/entityDefs/UserState.json | 32 ++++++ .../Espo/Tools/Currency/SyncManager.php | 9 ++ 18 files changed, 573 insertions(+), 6 deletions(-) create mode 100644 application/Espo/Core/Loaders/SystemState.php create mode 100644 application/Espo/Core/Rebuild/Actions/AddSystemState.php create mode 100644 application/Espo/Core/Utils/System/SystemState.php create mode 100644 application/Espo/Core/Utils/User/UserStateProvider.php create mode 100644 application/Espo/Entities/SystemState.php create mode 100644 application/Espo/Entities/UserState.php create mode 100644 application/Espo/Hooks/User/DeleteUserState.php create mode 100644 application/Espo/Resources/metadata/entityAcl/SystemState.json create mode 100644 application/Espo/Resources/metadata/entityAcl/UserState.json create mode 100644 application/Espo/Resources/metadata/entityDefs/SystemState.json create mode 100644 application/Espo/Resources/metadata/entityDefs/UserState.json diff --git a/application/Espo/Binding.php b/application/Espo/Binding.php index d3d614f8282..127425d96dc 100644 --- a/application/Espo/Binding.php +++ b/application/Espo/Binding.php @@ -257,6 +257,11 @@ private function bindServices(Binder $binder): void 'Espo\\Core\\Job\\JobManager', 'jobManager' ); + + $binder->bindService( + 'Espo\\Core\\Utils\\System\\SystemState', + 'systemState' + ); } private function bindCore(Binder $binder): void diff --git a/application/Espo/Core/Acl/Cache/Clearer.php b/application/Espo/Core/Acl/Cache/Clearer.php index 6e2d5af982a..a6e75a65073 100644 --- a/application/Espo/Core/Acl/Cache/Clearer.php +++ b/application/Espo/Core/Acl/Cache/Clearer.php @@ -30,6 +30,7 @@ namespace Espo\Core\Acl\Cache; use Espo\Core\Utils\File\Manager as FileManager; +use Espo\Core\Utils\System\SystemState; use Espo\Entities\Portal; use Espo\Entities\User; use Espo\ORM\EntityManager; @@ -40,19 +41,26 @@ */ class Clearer { - public function __construct(private FileManager $fileManager, private EntityManager $entityManager) - {} + public function __construct( + private FileManager $fileManager, + private EntityManager $entityManager, + private SystemState $systemState, + ) {} public function clearForAllInternalUsers(): void { $this->fileManager->removeInDir('data/cache/application/acl'); $this->fileManager->removeInDir('data/cache/application/aclMap'); + + $this->bumpSystemStateVersionNumber(); } public function clearForAllPortalUsers(): void { $this->fileManager->removeInDir('data/cache/application/aclPortal'); $this->fileManager->removeInDir('data/cache/application/aclPortalMap'); + + $this->bumpSystemStateVersionNumber(); } public function clearForUser(User $user): void @@ -67,6 +75,8 @@ public function clearForUser(User $user): void $this->fileManager->remove('data/cache/application/acl/' . $part); $this->fileManager->remove('data/cache/application/aclMap/' . $part); + + $this->bumpSystemStateVersionNumber(); } private function clearForPortalUser(User $user): void @@ -82,5 +92,12 @@ private function clearForPortalUser(User $user): void $this->fileManager->remove('data/cache/application/aclPortal/' . $part); $this->fileManager->remove('data/cache/application/aclPortalMap/' . $part); } + + $this->bumpSystemStateVersionNumber(); + } + + private function bumpSystemStateVersionNumber(): void + { + $this->systemState->bumpVersionNumber(); } } diff --git a/application/Espo/Core/Loaders/SystemState.php b/application/Espo/Core/Loaders/SystemState.php new file mode 100644 index 00000000000..e263346efac --- /dev/null +++ b/application/Espo/Core/Loaders/SystemState.php @@ -0,0 +1,49 @@ +. + * + * 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\Loaders; + +use Espo\Core\Container\Loader; +use Espo\Core\InjectableFactory; +use Espo\Core\Utils\System\SystemState as SystemStateService; + +/** + * @noinspection PhpUnused + */ +class SystemState implements Loader +{ + public function __construct( + private InjectableFactory $injectableFactory, + ) {} + + public function load() + { + return $this->injectableFactory->create(SystemStateService::class); + } +} diff --git a/application/Espo/Core/ORM/EntityManagerProxy.php b/application/Espo/Core/ORM/EntityManagerProxy.php index 54802f739ee..ad40dd0d735 100644 --- a/application/Espo/Core/ORM/EntityManagerProxy.php +++ b/application/Espo/Core/ORM/EntityManagerProxy.php @@ -30,6 +30,7 @@ namespace Espo\Core\ORM; use Espo\ORM\Entity; +use Espo\ORM\Executor\QueryExecutor; use Espo\ORM\Metadata; use Espo\ORM\Repository\RDBRepository; use Espo\ORM\Repository\Repository; @@ -129,4 +130,14 @@ public function getRepositoryByClass(string $className): Repository { return $this->getEntityManager()->getRepositoryByClass($className); } + + /** + * Get the query executor. + * + * @since 10.1.0 + */ + public function getQueryExecutor(): QueryExecutor + { + return $this->getEntityManager()->getQueryExecutor(); + } } diff --git a/application/Espo/Core/Rebuild/Actions/AddSystemState.php b/application/Espo/Core/Rebuild/Actions/AddSystemState.php new file mode 100644 index 00000000000..05618f9092a --- /dev/null +++ b/application/Espo/Core/Rebuild/Actions/AddSystemState.php @@ -0,0 +1,53 @@ +. + * + * 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\Rebuild\Actions; + +use Espo\Core\Rebuild\RebuildAction; +use Espo\Entities\SystemState; +use Espo\ORM\EntityManager; +use Espo\ORM\Name\Attribute; + +class AddSystemState implements RebuildAction +{ + public function __construct( + private EntityManager $entityManager, + ) {} + + public function process(): void + { + $entity = $this->entityManager->getEntityById(SystemState::ENTITY_TYPE, SystemState::ID_VALUE); + + if ($entity) { + return; + } + + $this->entityManager->createEntity(SystemState::ENTITY_TYPE, [Attribute::ID => SystemState::ID_VALUE]); + } +} diff --git a/application/Espo/Core/Utils/EmailFilterManager.php b/application/Espo/Core/Utils/EmailFilterManager.php index 26059a78d65..ef8acd42478 100644 --- a/application/Espo/Core/Utils/EmailFilterManager.php +++ b/application/Espo/Core/Utils/EmailFilterManager.php @@ -32,6 +32,7 @@ use Espo\Core\ORM\EntityManager; use Espo\Core\Mail\FiltersMatcher; use Espo\Core\Utils\Config\SystemConfig; +use Espo\Core\Utils\User\UserStateProvider; use Espo\Entities\Email; use Espo\Entities\EmailFilter; use Espo\Entities\User; @@ -49,13 +50,17 @@ class EmailFilterManager private array $data = []; private bool $useCache; - private const CACHE_KEY = 'emailFilters'; + /** @var array */ + private array $cacheVersionMap = []; + + private const string CACHE_KEY = 'emailFilters'; public function __construct( private EntityManager $entityManager, private FiltersMatcher $filtersMatcher, private DataCache $dataCache, SystemConfig $systemConfig, + private UserStateProvider $userStateProvider, ) { $this->useCache = $systemConfig->useCache(); } @@ -72,15 +77,19 @@ public function getMatchingFilter(Email $email, string $userId): ?EmailFilter */ private function get(string $userId): array { - if (array_key_exists($userId, $this->data)) { + if (array_key_exists($userId, $this->data) && $this->cacheIsRelevant($userId)) { return $this->data[$userId]; } + unset($this->data[$userId]); + $cacheKey = $this->composeCacheKey($userId); if ($this->useCache && $this->dataCache->has($cacheKey)) { $this->data[$userId] = $this->loadFromCache($cacheKey); + $this->setCacheVersionNumber($userId); + return $this->data[$userId]; } @@ -90,6 +99,8 @@ private function get(string $userId): array $this->storeToCache($userId); } + $this->setCacheVersionNumber($userId); + return $this->data[$userId]; } @@ -163,4 +174,20 @@ private function storeToCache(string $userId): void $this->dataCache->store($cacheKey, $dataList); } + + private function setCacheVersionNumber(string $userId): void + { + $this->cacheVersionMap[$userId] = $this->userStateProvider->getEmailFiltersVersionNumber($userId); + } + + private function cacheIsRelevant(string $userId): bool + { + $versionNumber = $this->cacheVersionMap[$userId] ?? null; + + if ($versionNumber === null) { + return false; + } + + return $versionNumber === $this->userStateProvider->getEmailFiltersVersionNumber($userId); + } } diff --git a/application/Espo/Core/Utils/System/SystemState.php b/application/Espo/Core/Utils/System/SystemState.php new file mode 100644 index 00000000000..fee6cc5c706 --- /dev/null +++ b/application/Espo/Core/Utils/System/SystemState.php @@ -0,0 +1,70 @@ +. + * + * 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\System; + +use Espo\Core\ORM\EntityManagerProxy; +use Espo\Entities\SystemState as State; +use Espo\ORM\Name\Attribute; +use Espo\ORM\Query\Part\Expression as Expr; +use Espo\ORM\Query\UpdateBuilder; + +/** + * @since 10.1.0 + */ +class SystemState +{ + public function __construct( + private EntityManagerProxy $entityManager, + ) {} + + public function getVersionNumber(): int + { + $entity = $this->entityManager + ->getRDBRepositoryByClass(State::class) + ->getById(State::ID_VALUE); + + return $entity?->getVersionNumber() ?? 0; + } + + public function bumpVersionNumber(): void + { + $query = UpdateBuilder::create() + ->in(State::ENTITY_TYPE) + ->set([ + State::FIELD_VERSION_NUMBER => Expr::add(Expr::column(State::FIELD_VERSION_NUMBER), 1), + ]) + ->where([ + Attribute::ID => State::ID_VALUE, + ]) + ->build(); + + $this->entityManager->getQueryExecutor()->execute($query); + } +} diff --git a/application/Espo/Core/Utils/User/UserStateProvider.php b/application/Espo/Core/Utils/User/UserStateProvider.php new file mode 100644 index 00000000000..b6ee139824e --- /dev/null +++ b/application/Espo/Core/Utils/User/UserStateProvider.php @@ -0,0 +1,106 @@ +. + * + * 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\User; + +use Espo\Entities\User; +use Espo\Entities\UserState; +use Espo\ORM\EntityManager; +use Espo\ORM\Name\Attribute; +use Espo\ORM\Query\Part\Expression as Expr; +use Espo\ORM\Query\UpdateBuilder; + +/** + * @since 10.1.0 + * @internal + */ +class UserStateProvider +{ + public function __construct( + private EntityManager $entityManager, + ) {} + + public function getEmailFiltersVersionNumber(string $userId): int + { + $userState = $this->entityManager + ->getRDBRepositoryByClass(UserState::class) + ->where([UserState::ATTR_USER_ID => $userId]) + ->findOne(); + + return $userState?->getEmailFiltersVersionNumber() ?? 0; + } + + public function bumpEmailFiltersVersionNumber(string $userId): void + { + $this->prepare($userId); + + $column = UserState::FIELD_EMAIL_FILTERS_VERSION_NUMBER; + + $query = UpdateBuilder::create() + ->in(UserState::ENTITY_TYPE) + ->set([ + $column => Expr::add(Expr::column($column), 1), + ]) + ->where([ + UserState::ATTR_USER_ID => $userId, + ]) + ->build(); + + $this->entityManager->getQueryExecutor()->execute($query); + } + + private function prepare(string $userId): void + { + $user = $this->entityManager->getRDBRepositoryByClass(User::class)->getById($userId); + + if (!$user) { + return; + } + + $userState = $this->entityManager + ->getRDBRepositoryByClass(UserState::class) + ->select(Attribute::ID) + ->where([UserState::ATTR_USER_ID => $user->getId()]) + ->findOne(); + + if ($userState) { + return; + } + + $userState = $this->entityManager->getRDBRepositoryByClass(UserState::class)->getNew(); + + $userState->set(UserState::ATTR_USER_ID, $user->getId()); + + $this->entityManager + ->getMapper() + ->insertOnDuplicateUpdate($userState, [UserState::ATTR_USER_ID]); + + $userState->setAsFetched(); + } +} diff --git a/application/Espo/Entities/SystemState.php b/application/Espo/Entities/SystemState.php new file mode 100644 index 00000000000..dfff37a5c8e --- /dev/null +++ b/application/Espo/Entities/SystemState.php @@ -0,0 +1,50 @@ +. + * + * 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\Entities; + +use Espo\Core\ORM\Entity; + +/** + * @internal + * @since 10.1.0 + */ +class SystemState extends Entity +{ + public const string ENTITY_TYPE = 'SystemState'; + + public const string ID_VALUE = '1'; + + public const string FIELD_VERSION_NUMBER = 'versionNumber'; + + public function getVersionNumber(): int + { + return $this->get(self::FIELD_VERSION_NUMBER) ?? 0; + } +} diff --git a/application/Espo/Entities/UserState.php b/application/Espo/Entities/UserState.php new file mode 100644 index 00000000000..41499f560e1 --- /dev/null +++ b/application/Espo/Entities/UserState.php @@ -0,0 +1,50 @@ +. + * + * 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\Entities; + +use Espo\Core\ORM\Entity; + +/** + * @internal + * @since 10.1.0 + */ +class UserState extends Entity +{ + public const string ENTITY_TYPE = 'UserState'; + + public const string FIELD_EMAIL_FILTERS_VERSION_NUMBER = 'emailFiltersVersionNumber'; + + public const string ATTR_USER_ID = 'userId'; + + public function getEmailFiltersVersionNumber(): int + { + return $this->get(self::FIELD_EMAIL_FILTERS_VERSION_NUMBER) ?? 0; + } +} diff --git a/application/Espo/Hooks/EmailFilter/CacheClearing.php b/application/Espo/Hooks/EmailFilter/CacheClearing.php index 60fd6f9c5cd..228fa251025 100644 --- a/application/Espo/Hooks/EmailFilter/CacheClearing.php +++ b/application/Espo/Hooks/EmailFilter/CacheClearing.php @@ -32,6 +32,7 @@ use Espo\Core\Hook\Hook\AfterRemove; use Espo\Core\Hook\Hook\AfterSave; use Espo\Core\Utils\DataCache; +use Espo\Core\Utils\User\UserStateProvider; use Espo\Entities\EmailFilter; use Espo\Entities\User; use Espo\ORM\Entity; @@ -44,9 +45,12 @@ */ class CacheClearing implements AfterSave, AfterRemove { - private const CACHE_KEY = 'emailFilters'; + private const string CACHE_KEY = 'emailFilters'; - public function __construct(private DataCache $dataCache) {} + public function __construct( + private DataCache $dataCache, + private UserStateProvider $userStateProvider, + ) {} /** * @param EmailFilter $entity @@ -73,6 +77,8 @@ private function processEntity(EmailFilter $entity): void $cacheKey = $this->composeCacheKey($entity->getParentId()); $this->dataCache->clear($cacheKey); + + $this->userStateProvider->bumpEmailFiltersVersionNumber($entity->getParentId()); } private function composeCacheKey(string $userId): string diff --git a/application/Espo/Hooks/User/DeleteUserState.php b/application/Espo/Hooks/User/DeleteUserState.php new file mode 100644 index 00000000000..1ff77b66d5c --- /dev/null +++ b/application/Espo/Hooks/User/DeleteUserState.php @@ -0,0 +1,60 @@ +. + * + * 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\Hooks\User; + +use Espo\Core\Hook\Hook\AfterRemove; +use Espo\Entities\User; +use Espo\Entities\UserState; +use Espo\ORM\Entity; +use Espo\ORM\EntityManager; +use Espo\ORM\Query\DeleteBuilder; +use Espo\ORM\Repository\Option\RemoveOptions; + +/** + * @implements AfterRemove + */ +class DeleteUserState implements AfterRemove +{ + public function __construct( + private EntityManager $entityManager, + ) {} + + public function afterRemove(Entity $entity, RemoveOptions $options): void + { + $query = DeleteBuilder::create() + ->from(UserState::ENTITY_TYPE) + ->where([ + UserState::ATTR_USER_ID => $entity->getId() + ]) + ->build(); + + $this->entityManager->getQueryExecutor()->execute($query); + } +} diff --git a/application/Espo/Resources/metadata/app/rebuild.json b/application/Espo/Resources/metadata/app/rebuild.json index 0e230586b15..a00b9701146 100644 --- a/application/Espo/Resources/metadata/app/rebuild.json +++ b/application/Espo/Resources/metadata/app/rebuild.json @@ -1,6 +1,7 @@ { "actionClassNameList": [ "Espo\\Core\\Rebuild\\Actions\\AddSystemUser", + "Espo\\Core\\Rebuild\\Actions\\AddSystemState", "Espo\\Core\\Rebuild\\Actions\\AddSystemData", "Espo\\Core\\Rebuild\\Actions\\VersionUpdate", "Espo\\Core\\Rebuild\\Actions\\ScheduledJobs", diff --git a/application/Espo/Resources/metadata/entityAcl/SystemState.json b/application/Espo/Resources/metadata/entityAcl/SystemState.json new file mode 100644 index 00000000000..e96a4427208 --- /dev/null +++ b/application/Espo/Resources/metadata/entityAcl/SystemState.json @@ -0,0 +1,3 @@ +{ + "systemWriteForbidden": true +} diff --git a/application/Espo/Resources/metadata/entityAcl/UserState.json b/application/Espo/Resources/metadata/entityAcl/UserState.json new file mode 100644 index 00000000000..e96a4427208 --- /dev/null +++ b/application/Espo/Resources/metadata/entityAcl/UserState.json @@ -0,0 +1,3 @@ +{ + "systemWriteForbidden": true +} diff --git a/application/Espo/Resources/metadata/entityDefs/SystemState.json b/application/Espo/Resources/metadata/entityDefs/SystemState.json new file mode 100644 index 00000000000..d98c2d4cb82 --- /dev/null +++ b/application/Espo/Resources/metadata/entityDefs/SystemState.json @@ -0,0 +1,15 @@ +{ + "fields": { + "id": { + "type": "id", + "dbType": "string", + "maxLength": 1 + }, + "versionNumber": { + "type": "int", + "dbType": "bigint", + "default": 0 + } + }, + "noDeletedAttribute": true +} diff --git a/application/Espo/Resources/metadata/entityDefs/UserState.json b/application/Espo/Resources/metadata/entityDefs/UserState.json new file mode 100644 index 00000000000..fb87d1b809b --- /dev/null +++ b/application/Espo/Resources/metadata/entityDefs/UserState.json @@ -0,0 +1,32 @@ +{ + "fields": { + "id": { + "type": "id", + "dbType": "integer", + "autoincrement": true + }, + "user": { + "type": "link" + }, + "emailFiltersVersionNumber": { + "type": "int", + "dbType": "bigint", + "default": 0 + } + }, + "links": { + "user": { + "type": "belongsTo", + "entity": "User" + } + }, + "indexes": { + "userId": { + "unique": true, + "columns": [ + "userId" + ] + } + }, + "noDeletedAttribute": true +} diff --git a/application/Espo/Tools/Currency/SyncManager.php b/application/Espo/Tools/Currency/SyncManager.php index 6442b90c9a3..efa8d26b118 100644 --- a/application/Espo/Tools/Currency/SyncManager.php +++ b/application/Espo/Tools/Currency/SyncManager.php @@ -33,6 +33,7 @@ use Espo\Core\Utils\Config\ConfigWriter; use Espo\Core\Utils\Config\SystemConfig; use Espo\Core\Utils\DataCache; +use Espo\Core\Utils\System\SystemState; use Espo\Entities\CurrencyRecord; use Espo\ORM\EntityManager; use Espo\ORM\Name\Attribute; @@ -54,6 +55,7 @@ public function __construct( private RateEntryProvider $rateEntryProvider, private DataCache $dataCache, private SystemConfig $systemConfig, + private SystemState $systemState, ) {} public function sync(): void @@ -127,6 +129,7 @@ public function refreshCache(): void $this->syncToConfigInTransaction(); }); + $this->getBumpVersionNumber(); $this->clearCache(); } @@ -154,6 +157,7 @@ public function updateCode(string $code): void $this->configWriter->set('currencyRates', $rates); $this->configWriter->save(); + $this->getBumpVersionNumber(); $this->clearCache(); } @@ -165,4 +169,9 @@ private function clearCache(): void $this->dataCache->clear($this->cacheKey); } + + private function getBumpVersionNumber(): void + { + $this->systemState->bumpVersionNumber(); + } } From 3640cc0037668bd3de6c625e23da0857ca8fac95 Mon Sep 17 00:00:00 2001 From: Yurii Date: Fri, 17 Jul 2026 10:48:07 +0300 Subject: [PATCH 23/87] Event dispatcher --- application/Espo/Binding.php | 10 ++ .../Core/Job/Processing/Util/ExitPolicy.php | 6 +- .../Espo/Core/Job/Processing/WorkerDaemon.php | 4 + .../Espo/Core/Loaders/EventDispatcher.php | 55 +++++++ .../Event/BypassEventDispatcherTransport.php | 46 ++++++ .../Espo/Core/Utils/Event/Configuration.php | 55 +++++++ application/Espo/Core/Utils/Event/Context.php | 44 ++++++ .../Core/Utils/Event/CrossInstanceEvent.php | 39 +++++ .../Event/CrossInstanceEventDispatcher.php | 138 ++++++++++++++++++ .../Espo/Core/Utils/Event/Envelope.php | 44 ++++++ application/Espo/Core/Utils/Event/Event.php | 36 +++++ .../Espo/Core/Utils/Event/EventDispatcher.php | 112 ++++++++++++++ .../Utils/Event/EventDispatcherTransport.php | 47 ++++++ .../Event/EventDispatcherTransportLoader.php | 48 ++++++ .../Espo/Core/Utils/Event/OriginProvider.php | 47 ++++++ .../metadata/app/containerServices.json | 6 + .../CrossInstanceEventDispatcherTest.php | 79 ++++++++++ .../Core/Utils/Event/EventDispatcherTest.php | 95 ++++++++++++ .../Espo/Core/Utils/Event/TestCiEvent1.php | 52 +++++++ .../Espo/Core/Utils/Event/TestCiEvent2.php | 46 ++++++ .../unit/Espo/Core/Utils/Event/TestEvent1.php | 35 +++++ .../unit/Espo/Core/Utils/Event/TestEvent2.php | 35 +++++ .../Espo/Core/Utils/Event/TestTransport.php | 74 ++++++++++ 23 files changed, 1152 insertions(+), 1 deletion(-) create mode 100644 application/Espo/Core/Loaders/EventDispatcher.php create mode 100644 application/Espo/Core/Utils/Event/BypassEventDispatcherTransport.php create mode 100644 application/Espo/Core/Utils/Event/Configuration.php create mode 100644 application/Espo/Core/Utils/Event/Context.php create mode 100644 application/Espo/Core/Utils/Event/CrossInstanceEvent.php create mode 100644 application/Espo/Core/Utils/Event/CrossInstanceEventDispatcher.php create mode 100644 application/Espo/Core/Utils/Event/Envelope.php create mode 100644 application/Espo/Core/Utils/Event/Event.php create mode 100644 application/Espo/Core/Utils/Event/EventDispatcher.php create mode 100644 application/Espo/Core/Utils/Event/EventDispatcherTransport.php create mode 100644 application/Espo/Core/Utils/Event/EventDispatcherTransportLoader.php create mode 100644 application/Espo/Core/Utils/Event/OriginProvider.php create mode 100644 tests/unit/Espo/Core/Utils/Event/CrossInstanceEventDispatcherTest.php create mode 100644 tests/unit/Espo/Core/Utils/Event/EventDispatcherTest.php create mode 100644 tests/unit/Espo/Core/Utils/Event/TestCiEvent1.php create mode 100644 tests/unit/Espo/Core/Utils/Event/TestCiEvent2.php create mode 100644 tests/unit/Espo/Core/Utils/Event/TestEvent1.php create mode 100644 tests/unit/Espo/Core/Utils/Event/TestEvent2.php create mode 100644 tests/unit/Espo/Core/Utils/Event/TestTransport.php diff --git a/application/Espo/Binding.php b/application/Espo/Binding.php index 127425d96dc..a6b2a788239 100644 --- a/application/Espo/Binding.php +++ b/application/Espo/Binding.php @@ -262,6 +262,16 @@ private function bindServices(Binder $binder): void 'Espo\\Core\\Utils\\System\\SystemState', 'systemState' ); + + $binder->bindService( + 'Espo\\Core\\Utils\\Event\\Configuration', + 'eventDispatcherConfiguration', + ); + + $binder->bindService( + 'Espo\\Core\\Utils\\Event\\EventDispatcherTransport', + 'eventDispatcherTransport' + ); } private function bindCore(Binder $binder): void diff --git a/application/Espo/Core/Job/Processing/Util/ExitPolicy.php b/application/Espo/Core/Job/Processing/Util/ExitPolicy.php index b656ad153a0..68a8d3793d5 100644 --- a/application/Espo/Core/Job/Processing/Util/ExitPolicy.php +++ b/application/Espo/Core/Job/Processing/Util/ExitPolicy.php @@ -31,6 +31,7 @@ use Espo\Core\Utils\Config; use Espo\Core\Utils\Config\StateConfig; +use Espo\Core\Utils\Event\EventDispatcherTransport; class ExitPolicy { @@ -39,12 +40,15 @@ class ExitPolicy public function __construct( private StateConfig $stateConfig, private Config\StateConfigDirect $stateConfigDirect, + private EventDispatcherTransport $eventDispatcherTransport, ) { $this->cacheTimestamp = $this->stateConfig->getCacheTimestamp(); } public function toExit(): bool { - return $this->cacheTimestamp !== $this->stateConfigDirect->getCacheTimestamp(); + return + $this->cacheTimestamp !== $this->stateConfigDirect->getCacheTimestamp() || + $this->eventDispatcherTransport->shouldReconnect(); } } diff --git a/application/Espo/Core/Job/Processing/WorkerDaemon.php b/application/Espo/Core/Job/Processing/WorkerDaemon.php index fb3ee3b0d45..e079ffe6ca6 100644 --- a/application/Espo/Core/Job/Processing/WorkerDaemon.php +++ b/application/Espo/Core/Job/Processing/WorkerDaemon.php @@ -31,6 +31,7 @@ use Espo\Core\Job\Processing\Consumer\Params; use Espo\Core\Job\Processing\Util\ExitSetup; +use Espo\Core\Utils\Event\Configuration; /** * @since 10.1.0 @@ -41,6 +42,7 @@ class WorkerDaemon public function __construct( private Consumer $consumer, private ExitSetup $exitSetup, + private Configuration $eventConfiguration, ) {} public function run(WorkerDaemon\Params $params): void @@ -49,6 +51,8 @@ public function run(WorkerDaemon\Params $params): void $this->setupExit(); + $this->eventConfiguration->setSubscribeToCrossInstanceEvents(true); + $this->consumer->start($consumerParams); } diff --git a/application/Espo/Core/Loaders/EventDispatcher.php b/application/Espo/Core/Loaders/EventDispatcher.php new file mode 100644 index 00000000000..703166f0cde --- /dev/null +++ b/application/Espo/Core/Loaders/EventDispatcher.php @@ -0,0 +1,55 @@ +. + * + * 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\Loaders; + +use Espo\Core\Binding\BindingContainerBuilder; +use Espo\Core\Container\Loader; +use Espo\Core\InjectableFactory; +use Espo\Core\Utils\Event\EventDispatcher as EventDispatcherService; +use Espo\Core\Utils\Event\OriginProvider; + +class EventDispatcher implements Loader +{ + public function __construct( + private InjectableFactory $injectableFactory, + ) {} + + public function load(): EventDispatcherService + { + $originProvider = $this->injectableFactory->create(OriginProvider::class); + + return $this->injectableFactory->createWithBinding( + EventDispatcherService::class, + BindingContainerBuilder::create() + ->bindInstance(OriginProvider::class, $originProvider) + ->build() + ); + } +} diff --git a/application/Espo/Core/Utils/Event/BypassEventDispatcherTransport.php b/application/Espo/Core/Utils/Event/BypassEventDispatcherTransport.php new file mode 100644 index 00000000000..9e635da90e3 --- /dev/null +++ b/application/Espo/Core/Utils/Event/BypassEventDispatcherTransport.php @@ -0,0 +1,46 @@ +. + * + * 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\Event; + +use Closure; + +class BypassEventDispatcherTransport implements EventDispatcherTransport +{ + public function subscribe(Closure $callback): void + {} + + public function dispatch(Envelope $envelope): void + {} + + public function shouldReconnect(): bool + { + return false; + } +} diff --git a/application/Espo/Core/Utils/Event/Configuration.php b/application/Espo/Core/Utils/Event/Configuration.php new file mode 100644 index 00000000000..fb83e9d720e --- /dev/null +++ b/application/Espo/Core/Utils/Event/Configuration.php @@ -0,0 +1,55 @@ +. + * + * 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\Event; + +/** + * @internal + */ +class Configuration +{ + public function __construct( + private bool $subscribeToCrossInstanceEvents = false, + ) {} + + public function subscribeToCrossInstanceEvents(): bool + { + return $this->subscribeToCrossInstanceEvents; + } + + /** + * @internal + */ + public function setSubscribeToCrossInstanceEvents(bool $value): self + { + $this->subscribeToCrossInstanceEvents = $value; + + return $this; + } +} diff --git a/application/Espo/Core/Utils/Event/Context.php b/application/Espo/Core/Utils/Event/Context.php new file mode 100644 index 00000000000..59471cddc81 --- /dev/null +++ b/application/Espo/Core/Utils/Event/Context.php @@ -0,0 +1,44 @@ +. + * + * 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\Event; + +/** + * @since 10.1.0 + */ +readonly class Context +{ + /** + * @internal + */ + public function __construct( + public bool $isLocal, + public string $origin, + ) {} +} diff --git a/application/Espo/Core/Utils/Event/CrossInstanceEvent.php b/application/Espo/Core/Utils/Event/CrossInstanceEvent.php new file mode 100644 index 00000000000..63ab30a0d36 --- /dev/null +++ b/application/Espo/Core/Utils/Event/CrossInstanceEvent.php @@ -0,0 +1,39 @@ +. + * + * 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\Event; + +use stdClass; + +interface CrossInstanceEvent extends Event +{ + public static function fromRaw(stdClass $payload): static; + + public function toRaw(): stdClass; +} diff --git a/application/Espo/Core/Utils/Event/CrossInstanceEventDispatcher.php b/application/Espo/Core/Utils/Event/CrossInstanceEventDispatcher.php new file mode 100644 index 00000000000..70e7cb5f9dc --- /dev/null +++ b/application/Espo/Core/Utils/Event/CrossInstanceEventDispatcher.php @@ -0,0 +1,138 @@ +. + * + * 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\Event; + +use Closure; +use RuntimeException; +use Throwable; + +class CrossInstanceEventDispatcher +{ + /** + * @var array, (Closure(CrossInstanceEvent, Context): void)[]> + */ + private array $callbacks = []; + + private bool $isTransportSubscribed = false; + + public function __construct( + private EventDispatcherTransport $transport, + private OriginProvider $originProvider, + ) {} + + /** + * @param class-string $className + * @param Closure(CrossInstanceEvent, Context): void $callback + */ + public function subscribe(string $className, Closure $callback): void + { + $this->ensureSubscribeTransport(); + + $this->callbacks[$className] ??= []; + $this->callbacks[$className][] = $callback; + } + + /** + * @param class-string $className + * @param Closure(CrossInstanceEvent, Context): void $callback + */ + public function unsubscribe(string $className, Closure $callback): void + { + if (!array_key_exists($className, $this->callbacks)) { + return; + } + + $list = &$this->callbacks[$className]; + + $index = array_search($callback, $list); + + if ($index !== false) { + unset($list[$index]); + + $list = array_values($list); + } + } + + public function dispatch(CrossInstanceEvent $event): void + { + $envelope = new Envelope( + eventClassName: $event::class, + payload: $event->toRaw(), + origin: $this->originProvider->get(), + ); + + $this->transport->dispatch($envelope); + } + + private function ensureSubscribeTransport(): void + { + if ($this->isTransportSubscribed) { + return; + } + + $this->transport->subscribe(fn (Envelope $envelope) => $this->transportCallback($envelope)); + + $this->isTransportSubscribed = true; + } + + private function transportCallback(Envelope $envelope): void + { + if ($envelope->origin === $this->originProvider->get()) { + return; + } + + $className = $envelope->eventClassName; + + if (!is_subclass_of($className, CrossInstanceEvent::class)) { + throw new RuntimeException("Non-valid event class name."); + } + + $callbacks = $this->callbacks[$className] ?? []; + + if ($callbacks === []) { + return; + } + + try { + $event = $className::fromRaw($envelope->payload); + } catch (Throwable $e) { + throw new RuntimeException("Could not hydrate event '$className'.", previous: $e); + } + + $context = new Context( + isLocal: false, + origin: $envelope->origin, + ); + + foreach ($callbacks as $callback) { + $callback($event, $context); + } + } +} diff --git a/application/Espo/Core/Utils/Event/Envelope.php b/application/Espo/Core/Utils/Event/Envelope.php new file mode 100644 index 00000000000..7d2c08dfd6f --- /dev/null +++ b/application/Espo/Core/Utils/Event/Envelope.php @@ -0,0 +1,44 @@ +. + * + * 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\Event; + +use stdClass; + +readonly class Envelope +{ + /** + * @param class-string $eventClassName + */ + public function __construct( + public string $eventClassName, + public stdClass $payload, + public string $origin, + ) {} +} diff --git a/application/Espo/Core/Utils/Event/Event.php b/application/Espo/Core/Utils/Event/Event.php new file mode 100644 index 00000000000..d80eb1b0c23 --- /dev/null +++ b/application/Espo/Core/Utils/Event/Event.php @@ -0,0 +1,36 @@ +. + * + * 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\Event; + +/** + * @since 10.1.0 + */ +interface Event +{} diff --git a/application/Espo/Core/Utils/Event/EventDispatcher.php b/application/Espo/Core/Utils/Event/EventDispatcher.php new file mode 100644 index 00000000000..ba8e52fd72f --- /dev/null +++ b/application/Espo/Core/Utils/Event/EventDispatcher.php @@ -0,0 +1,112 @@ +. + * + * 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\Event; + +use Closure; + +/** + * @since 10.1.0 + */ +class EventDispatcher +{ + /** + * @var array, (Closure(Event, Context): void)[]> + */ + private array $callbacks = []; + + public function __construct( + private OriginProvider $originProvider, + private CrossInstanceEventDispatcher $crossInstanceDispatcher, + private Configuration $configuration, + ) {} + + /** + * @param class-string $className + * @param Closure(Event, Context): void $callback + */ + public function subscribe(string $className, Closure $callback): void + { + $this->callbacks[$className] ??= []; + $this->callbacks[$className][] = $callback; + + if ( + $this->configuration->subscribeToCrossInstanceEvents() && + is_subclass_of($className, CrossInstanceEvent::class) + ) { + $this->crossInstanceDispatcher->subscribe($className, $callback); + } + } + + /** + * @param class-string $className + * @param Closure(Event, Context): void $callback + */ + public function unsubscribe(string $className, Closure $callback): void + { + if (!array_key_exists($className, $this->callbacks)) { + return; + } + + $list = &$this->callbacks[$className]; + + $index = array_search($callback, $list); + + if ($index !== false) { + unset($list[$index]); + + $list = array_values($list); + } + + if ( + $this->configuration->subscribeToCrossInstanceEvents() && + is_subclass_of($className, CrossInstanceEvent::class) + ) { + $this->crossInstanceDispatcher->unsubscribe($className, $callback); + } + } + + public function dispatch(Event $event): void + { + $callbacks = $this->callbacks[$event::class] ?? []; + + $localContext = new Context( + isLocal: true, + origin: $this->originProvider->get(), + ); + + foreach ($callbacks as $callback) { + $callback($event, $localContext); + } + + if ($event instanceof CrossInstanceEvent) { + $this->crossInstanceDispatcher->dispatch($event); + } + } +} diff --git a/application/Espo/Core/Utils/Event/EventDispatcherTransport.php b/application/Espo/Core/Utils/Event/EventDispatcherTransport.php new file mode 100644 index 00000000000..4516c6b988d --- /dev/null +++ b/application/Espo/Core/Utils/Event/EventDispatcherTransport.php @@ -0,0 +1,47 @@ +. + * + * 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\Event; + +use Closure; + +/** + * @since 10.1.0 + */ +interface EventDispatcherTransport +{ + /** + * @param Closure(Envelope): void $callback + */ + public function subscribe(Closure $callback): void; + + public function dispatch(Envelope $envelope): void; + + public function shouldReconnect(): bool; +} diff --git a/application/Espo/Core/Utils/Event/EventDispatcherTransportLoader.php b/application/Espo/Core/Utils/Event/EventDispatcherTransportLoader.php new file mode 100644 index 00000000000..4e4e7fba3bb --- /dev/null +++ b/application/Espo/Core/Utils/Event/EventDispatcherTransportLoader.php @@ -0,0 +1,48 @@ +. + * + * 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\Event; + +use Espo\Core\Container\Loader; +use Espo\Core\InjectableFactory; + +/** + * @noinspection PhpUnused + */ +class EventDispatcherTransportLoader implements Loader +{ + public function __construct( + private InjectableFactory $injectableFactory, + ) {} + + public function load(): EventDispatcherTransport + { + return $this->injectableFactory->create(BypassEventDispatcherTransport::class); + } +} diff --git a/application/Espo/Core/Utils/Event/OriginProvider.php b/application/Espo/Core/Utils/Event/OriginProvider.php new file mode 100644 index 00000000000..e98297ecf4f --- /dev/null +++ b/application/Espo/Core/Utils/Event/OriginProvider.php @@ -0,0 +1,47 @@ +. + * + * 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\Event; + +use Espo\Core\Utils\Util; + +/** + * @since 10.1.0 + */ +class OriginProvider +{ + private ?string $value = null; + + public function get(): string + { + $this->value ??= Util::generateId(); + + return $this->value; + } +} diff --git a/application/Espo/Resources/metadata/app/containerServices.json b/application/Espo/Resources/metadata/app/containerServices.json index 8af3f9a59bc..172a4a2eaf3 100644 --- a/application/Espo/Resources/metadata/app/containerServices.json +++ b/application/Espo/Resources/metadata/app/containerServices.json @@ -91,5 +91,11 @@ }, "session": { "className": "Espo\\Core\\Session\\DefaultSession" + }, + "eventDispatcherConfiguration": { + "className": "Espo\\Core\\Utils\\Event\\Configuration" + }, + "eventDispatcherTransport": { + "loaderClassName": "Espo\\Core\\Utils\\Event\\EventDispatcherTransportLoader" } } diff --git a/tests/unit/Espo/Core/Utils/Event/CrossInstanceEventDispatcherTest.php b/tests/unit/Espo/Core/Utils/Event/CrossInstanceEventDispatcherTest.php new file mode 100644 index 00000000000..b8e9cb80bd8 --- /dev/null +++ b/tests/unit/Espo/Core/Utils/Event/CrossInstanceEventDispatcherTest.php @@ -0,0 +1,79 @@ +. + * + * 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 tests\unit\Espo\Core\Utils\Event; + +use Espo\Core\Utils\Event\Context; +use Espo\Core\Utils\Event\CrossInstanceEventDispatcher; +use Espo\Core\Utils\Event\OriginProvider; +use PHPUnit\Framework\TestCase; + +class CrossInstanceEventDispatcherTest extends TestCase +{ + public function testDispatching(): void + { + $originProvider = $this->getOriginProvider(); + + $transport = new TestTransport(); + + $dispatcher = new CrossInstanceEventDispatcher( + transport: $transport, + originProvider: $originProvider, + ); + + $value = false; + + $callback1 = function (TestCiEvent1 $event, Context $context) use (&$value) { + if (!$context->isLocal && $event->value === 'hello') { + $value = true; + } + }; + + $callback2 = function () use (&$value) { + $value = false; + }; + + $dispatcher->subscribe(TestCiEvent1::class, $callback1); + $dispatcher->subscribe(TestCiEvent2::class, $callback2); + + $transport->dispatchForTest(TestCiEvent1::class, (object) ['value' => 'hello']); + + $this->assertTrue($value); + } + + private function getOriginProvider(): OriginProvider + { + $originProvider = $this->createMock(OriginProvider::class); + + $originProvider->method('get') + ->willReturn('test-id'); + + return $originProvider; + } +} diff --git a/tests/unit/Espo/Core/Utils/Event/EventDispatcherTest.php b/tests/unit/Espo/Core/Utils/Event/EventDispatcherTest.php new file mode 100644 index 00000000000..e8c2407259a --- /dev/null +++ b/tests/unit/Espo/Core/Utils/Event/EventDispatcherTest.php @@ -0,0 +1,95 @@ +. + * + * 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 tests\unit\Espo\Core\Utils\Event; + +use Espo\Core\Utils\Event\Configuration; +use Espo\Core\Utils\Event\Context; +use Espo\Core\Utils\Event\CrossInstanceEventDispatcher; +use Espo\Core\Utils\Event\EventDispatcher; +use Espo\Core\Utils\Event\OriginProvider; +use PHPUnit\Framework\TestCase; + +class EventDispatcherTest extends TestCase +{ + public function testLocalDispatching() : void + { + $originProvider = $this->getOriginProvider(); + $crossInstanceDispatcher = $this->createMock(CrossInstanceEventDispatcher::class); + + $dispatcher = new EventDispatcher( + originProvider: $originProvider, + crossInstanceDispatcher: $crossInstanceDispatcher, + configuration: $this->createMock(Configuration::class), + ); + + // + + $value = false; + + $callback1 = function (TestEvent1 $event, Context $context) use (&$value, $originProvider) { + if ($context->isLocal && $context->origin === $originProvider->get()) { + $value = true; + } + }; + + $dispatcher->subscribe(TestEvent1::class, $callback1); + + $dispatcher->subscribe( + TestEvent2::class, + function () use (&$value) { + $value = false; + } + ); + + $dispatcher->dispatch(new TestEvent1()); + + $this->assertTrue($value); + + // + + $dispatcher->unsubscribe(TestEvent1::class, $callback1); + + $value = false; + + $dispatcher->dispatch(new TestEvent1()); + + $this->assertFalse($value); + } + + private function getOriginProvider(): OriginProvider + { + $originProvider = $this->createMock(OriginProvider::class); + + $originProvider->method('get') + ->willReturn('test-id'); + + return $originProvider; + } +} diff --git a/tests/unit/Espo/Core/Utils/Event/TestCiEvent1.php b/tests/unit/Espo/Core/Utils/Event/TestCiEvent1.php new file mode 100644 index 00000000000..9e89933c60e --- /dev/null +++ b/tests/unit/Espo/Core/Utils/Event/TestCiEvent1.php @@ -0,0 +1,52 @@ +. + * + * 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 tests\unit\Espo\Core\Utils\Event; + +use Espo\Core\Utils\Event\CrossInstanceEvent; +use stdClass; + +class TestCiEvent1 implements CrossInstanceEvent +{ + public function __construct( + public string $value, + ) {} + + public static function fromRaw(stdClass $payload): static + { + return new self($payload->value ?? ''); + } + + public function toRaw(): stdClass + { + return (object) [ + 'value' => $this->value, + ]; + } +} diff --git a/tests/unit/Espo/Core/Utils/Event/TestCiEvent2.php b/tests/unit/Espo/Core/Utils/Event/TestCiEvent2.php new file mode 100644 index 00000000000..083164e09c3 --- /dev/null +++ b/tests/unit/Espo/Core/Utils/Event/TestCiEvent2.php @@ -0,0 +1,46 @@ +. + * + * 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 tests\unit\Espo\Core\Utils\Event; + +use Espo\Core\Utils\Event\CrossInstanceEvent; +use stdClass; + +class TestCiEvent2 implements CrossInstanceEvent +{ + public static function fromRaw(stdClass $payload): static + { + return new self(); + } + + public function toRaw(): stdClass + { + return (object) []; + } +} diff --git a/tests/unit/Espo/Core/Utils/Event/TestEvent1.php b/tests/unit/Espo/Core/Utils/Event/TestEvent1.php new file mode 100644 index 00000000000..01b8f214739 --- /dev/null +++ b/tests/unit/Espo/Core/Utils/Event/TestEvent1.php @@ -0,0 +1,35 @@ +. + * + * 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 tests\unit\Espo\Core\Utils\Event; + +use Espo\Core\Utils\Event\Event; + +class TestEvent1 implements Event +{} diff --git a/tests/unit/Espo/Core/Utils/Event/TestEvent2.php b/tests/unit/Espo/Core/Utils/Event/TestEvent2.php new file mode 100644 index 00000000000..469a065f71b --- /dev/null +++ b/tests/unit/Espo/Core/Utils/Event/TestEvent2.php @@ -0,0 +1,35 @@ +. + * + * 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 tests\unit\Espo\Core\Utils\Event; + +use Espo\Core\Utils\Event\Event; + +class TestEvent2 implements Event +{} diff --git a/tests/unit/Espo/Core/Utils/Event/TestTransport.php b/tests/unit/Espo/Core/Utils/Event/TestTransport.php new file mode 100644 index 00000000000..be217ad9d54 --- /dev/null +++ b/tests/unit/Espo/Core/Utils/Event/TestTransport.php @@ -0,0 +1,74 @@ +. + * + * 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 tests\unit\Espo\Core\Utils\Event; + +use Closure; +use Espo\Core\Utils\Event\Envelope; +use Espo\Core\Utils\Event\EventDispatcherTransport; +use stdClass; + +class TestTransport implements EventDispatcherTransport +{ + /** + * @var (Closure(Envelope): void)|null + */ + private ?Closure $callback = null; + + /** + * @param Closure(Envelope): void $callback + */ + public function subscribe(Closure $callback): void + { + $this->callback = $callback; + } + + public function dispatch(Envelope $envelope): void + {} + + public function shouldReconnect(): bool + { + return true; + } + + public function dispatchForTest(string $eventClassName, stdClass $payload): void + { + if (!$this->callback) { + return; + } + + $envelope = new Envelope( + eventClassName: $eventClassName, + payload: $payload, + origin: 'other', + ); + + ($this->callback)($envelope); + } +} From 24a8aa1931a0bccdc57be079493c1d05506ae0ff Mon Sep 17 00:00:00 2001 From: Yurii Date: Fri, 17 Jul 2026 14:40:31 +0300 Subject: [PATCH 24/87] Ticker and events --- application/Espo/Core/Acl/Cache/Clearer.php | 17 ++++- .../Acl/Events/InvalidatePortalUserCache.php | 73 +++++++++++++++++++ .../Core/Acl/Events/InvalidateUserCache.php | 64 ++++++++++++++++ .../Core/Currency/InternalRatesProvider.php | 7 ++ .../Job/Processing/Exceptions/TickFailure.php | 35 +++++++++ .../Core/Job/Processing/RabbitMq/Consumer.php | 11 +++ .../Core/Job/Processing/Util/ExitPolicy.php | 6 +- .../Espo/Core/Job/Processing/Util/Ticker.php | 53 ++++++++++++++ .../Espo/Core/Utils/Cache/DataCacheAccess.php | 2 - .../Event/BypassEventDispatcherTransport.php | 6 +- .../Core/Utils/Event/CrossInstanceEvent.php | 2 +- .../Utils/Event/EventDispatcherTransport.php | 6 +- .../Exceptions/TransportNotConnected.php | 35 +++++++++ .../Currency/Events/CurrencyRateUpdate.php | 49 +++++++++++++ .../Espo/Tools/Currency/SyncManager.php | 13 ++-- .../Espo/Core/Utils/Event/TestCiEvent1.php | 2 +- .../Espo/Core/Utils/Event/TestCiEvent2.php | 2 +- .../Espo/Core/Utils/Event/TestTransport.php | 6 +- 18 files changed, 360 insertions(+), 29 deletions(-) create mode 100644 application/Espo/Core/Acl/Events/InvalidatePortalUserCache.php create mode 100644 application/Espo/Core/Acl/Events/InvalidateUserCache.php create mode 100644 application/Espo/Core/Job/Processing/Exceptions/TickFailure.php create mode 100644 application/Espo/Core/Job/Processing/Util/Ticker.php create mode 100644 application/Espo/Core/Utils/Event/Exceptions/TransportNotConnected.php create mode 100644 application/Espo/Tools/Currency/Events/CurrencyRateUpdate.php diff --git a/application/Espo/Core/Acl/Cache/Clearer.php b/application/Espo/Core/Acl/Cache/Clearer.php index a6e75a65073..0e18376a275 100644 --- a/application/Espo/Core/Acl/Cache/Clearer.php +++ b/application/Espo/Core/Acl/Cache/Clearer.php @@ -29,6 +29,9 @@ namespace Espo\Core\Acl\Cache; +use Espo\Core\Acl\Events\InvalidatePortalUserCache; +use Espo\Core\Acl\Events\InvalidateUserCache; +use Espo\Core\Utils\Event\EventDispatcher; use Espo\Core\Utils\File\Manager as FileManager; use Espo\Core\Utils\System\SystemState; use Espo\Entities\Portal; @@ -37,7 +40,7 @@ use Espo\ORM\Name\Attribute; /** - * @todo Clear cache in AclManager. + * @todo Clear loaded data in AclManager. */ class Clearer { @@ -45,6 +48,7 @@ public function __construct( private FileManager $fileManager, private EntityManager $entityManager, private SystemState $systemState, + private EventDispatcher $eventDispatcher, ) {} public function clearForAllInternalUsers(): void @@ -76,7 +80,7 @@ public function clearForUser(User $user): void $this->fileManager->remove('data/cache/application/acl/' . $part); $this->fileManager->remove('data/cache/application/aclMap/' . $part); - $this->bumpSystemStateVersionNumber(); + $this->eventDispatcher->dispatch(new InvalidateUserCache($user->getId())); } private function clearForPortalUser(User $user): void @@ -91,9 +95,14 @@ private function clearForPortalUser(User $user): void $this->fileManager->remove('data/cache/application/aclPortal/' . $part); $this->fileManager->remove('data/cache/application/aclPortalMap/' . $part); - } - $this->bumpSystemStateVersionNumber(); + $event = new InvalidatePortalUserCache( + userId: $user->getId(), + portalId: $portal->getId(), + ); + + $this->eventDispatcher->dispatch($event); + } } private function bumpSystemStateVersionNumber(): void diff --git a/application/Espo/Core/Acl/Events/InvalidatePortalUserCache.php b/application/Espo/Core/Acl/Events/InvalidatePortalUserCache.php new file mode 100644 index 00000000000..535a88cb6e8 --- /dev/null +++ b/application/Espo/Core/Acl/Events/InvalidatePortalUserCache.php @@ -0,0 +1,73 @@ +. + * + * 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\Acl\Events; + +use Espo\Core\Utils\Event\CrossInstanceEvent; +use stdClass; +use UnexpectedValueException; + +/** + * @since 10.1.0 + */ +class InvalidatePortalUserCache implements CrossInstanceEvent +{ + public function __construct( + public string $userId, + public string $portalId, + ) {} + + public static function fromRaw(stdClass $payload): self + { + $userId = $payload->userId ?? null; + + if (!is_string($userId)) { + throw new UnexpectedValueException("No user ID."); + } + + $portalId = $payload->portalId ?? null; + + if (!is_string($portalId)) { + throw new UnexpectedValueException("No portal ID."); + } + + return new self( + userId: $userId, + portalId: $portalId, + ); + } + + public function toRaw(): stdClass + { + return (object) [ + 'userId' => $this->userId, + 'portalId' => $this->portalId, + ]; + } +} diff --git a/application/Espo/Core/Acl/Events/InvalidateUserCache.php b/application/Espo/Core/Acl/Events/InvalidateUserCache.php new file mode 100644 index 00000000000..ee4d009fc2b --- /dev/null +++ b/application/Espo/Core/Acl/Events/InvalidateUserCache.php @@ -0,0 +1,64 @@ +. + * + * 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\Acl\Events; + +use Espo\Core\Utils\Event\CrossInstanceEvent; +use stdClass; +use UnexpectedValueException; + +/** + * @since 10.1.0 + */ +readonly class InvalidateUserCache implements CrossInstanceEvent +{ + public function __construct( + public string $userId, + ) {} + + public static function fromRaw(stdClass $payload): self + { + $userId = $payload->userId ?? null; + + if (!is_string($userId)) { + throw new UnexpectedValueException(); + } + + return new self( + userId: $userId, + ); + } + + public function toRaw(): stdClass + { + return (object) [ + 'userId' => $this->userId, + ]; + } +} diff --git a/application/Espo/Core/Currency/InternalRatesProvider.php b/application/Espo/Core/Currency/InternalRatesProvider.php index efaa58ee66b..65a812a4e0e 100644 --- a/application/Espo/Core/Currency/InternalRatesProvider.php +++ b/application/Espo/Core/Currency/InternalRatesProvider.php @@ -32,6 +32,8 @@ use Espo\Core\Field\Date; use Espo\Core\Utils\Cache\DataCacheAccess; use Espo\Core\Utils\DateTime; +use Espo\Core\Utils\Event\EventDispatcher; +use Espo\Tools\Currency\Events\CurrencyRateUpdate; use LogicException; use RuntimeException; use stdClass; @@ -53,6 +55,7 @@ public function __construct( private DateTime $dateTime, private InternalRateEntryProvider $rateEntryProvider, private DataCacheAccess $dataCacheAccess, + private EventDispatcher $eventDispatcher, ) { $this->dataCacheAccess->init( key: $this->cacheKey, @@ -73,6 +76,10 @@ public function __construct( return $date === $this->today->toString(); }, ); + + $this->eventDispatcher->subscribe(CurrencyRateUpdate::class, function () { + $this->dataCacheAccess->reset(); + }); } /** diff --git a/application/Espo/Core/Job/Processing/Exceptions/TickFailure.php b/application/Espo/Core/Job/Processing/Exceptions/TickFailure.php new file mode 100644 index 00000000000..85cd7592a23 --- /dev/null +++ b/application/Espo/Core/Job/Processing/Exceptions/TickFailure.php @@ -0,0 +1,35 @@ +. + * + * 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\Job\Processing\Exceptions; + +use Exception; + +class TickFailure extends Exception +{} diff --git a/application/Espo/Core/Job/Processing/RabbitMq/Consumer.php b/application/Espo/Core/Job/Processing/RabbitMq/Consumer.php index c9a2e110a55..ab047f53354 100644 --- a/application/Espo/Core/Job/Processing/RabbitMq/Consumer.php +++ b/application/Espo/Core/Job/Processing/RabbitMq/Consumer.php @@ -30,10 +30,12 @@ namespace Espo\Core\Job\Processing\RabbitMq; use Espo\Core\Job\JobRunner; +use Espo\Core\Job\Processing\Exceptions\TickFailure; use Espo\Core\Job\Processing\JobProvider; use Espo\Core\Job\Processing\Consumer as ConsumerInterface; use Espo\Core\Job\Processing\Consumer\Params; use Espo\Core\Job\Processing\Util\ExitPolicy; +use Espo\Core\Job\Processing\Util\Ticker; use Espo\Core\Utils\Log; use Exception; use PhpAmqpLib\Channel\AMQPChannel; @@ -55,6 +57,7 @@ public function __construct( private Log $log, private JobProvider $jobProvider, private ExitPolicy $exitPolicy, + private Ticker $ticker, ) {} public function start(Params $params): void @@ -75,6 +78,14 @@ public function start(Params $params): void $count = 0; while ($channel->is_consuming()) { + try { + $this->ticker->tick(); + } catch (TickFailure $e) { + $this->log->warning("Tick failure.", ['exception' => $e]); + + break; + } + try { $channel->wait(timeout: self::ITERATION_TIMEOUT); } catch (AMQPTimeoutException) { diff --git a/application/Espo/Core/Job/Processing/Util/ExitPolicy.php b/application/Espo/Core/Job/Processing/Util/ExitPolicy.php index 68a8d3793d5..b656ad153a0 100644 --- a/application/Espo/Core/Job/Processing/Util/ExitPolicy.php +++ b/application/Espo/Core/Job/Processing/Util/ExitPolicy.php @@ -31,7 +31,6 @@ use Espo\Core\Utils\Config; use Espo\Core\Utils\Config\StateConfig; -use Espo\Core\Utils\Event\EventDispatcherTransport; class ExitPolicy { @@ -40,15 +39,12 @@ class ExitPolicy public function __construct( private StateConfig $stateConfig, private Config\StateConfigDirect $stateConfigDirect, - private EventDispatcherTransport $eventDispatcherTransport, ) { $this->cacheTimestamp = $this->stateConfig->getCacheTimestamp(); } public function toExit(): bool { - return - $this->cacheTimestamp !== $this->stateConfigDirect->getCacheTimestamp() || - $this->eventDispatcherTransport->shouldReconnect(); + return $this->cacheTimestamp !== $this->stateConfigDirect->getCacheTimestamp(); } } diff --git a/application/Espo/Core/Job/Processing/Util/Ticker.php b/application/Espo/Core/Job/Processing/Util/Ticker.php new file mode 100644 index 00000000000..9b203b05560 --- /dev/null +++ b/application/Espo/Core/Job/Processing/Util/Ticker.php @@ -0,0 +1,53 @@ +. + * + * 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\Job\Processing\Util; + +use Espo\Core\Job\Processing\Exceptions\TickFailure; +use Espo\Core\Utils\Event\EventDispatcherTransport; +use Espo\Core\Utils\Event\Exceptions\TransportNotConnected; + +class Ticker +{ + public function __construct( + private EventDispatcherTransport $transport + ) {} + + /** + * @throws TickFailure + */ + public function tick(): void + { + try { + $this->transport->tick(); + } catch (TransportNotConnected $e) { + throw new TickFailure(previous: $e); + } + } +} diff --git a/application/Espo/Core/Utils/Cache/DataCacheAccess.php b/application/Espo/Core/Utils/Cache/DataCacheAccess.php index ac5588743c2..d91f155150c 100644 --- a/application/Espo/Core/Utils/Cache/DataCacheAccess.php +++ b/application/Espo/Core/Utils/Cache/DataCacheAccess.php @@ -61,8 +61,6 @@ public function __construct( ) {} /** - * @todo Event clearing loaded data. - * * @param Closure(): T $loader * @param (Closure(T): bool)|null $validityChecker */ diff --git a/application/Espo/Core/Utils/Event/BypassEventDispatcherTransport.php b/application/Espo/Core/Utils/Event/BypassEventDispatcherTransport.php index 9e635da90e3..a1e12a30503 100644 --- a/application/Espo/Core/Utils/Event/BypassEventDispatcherTransport.php +++ b/application/Espo/Core/Utils/Event/BypassEventDispatcherTransport.php @@ -39,8 +39,6 @@ public function subscribe(Closure $callback): void public function dispatch(Envelope $envelope): void {} - public function shouldReconnect(): bool - { - return false; - } + public function tick(): void + {} } diff --git a/application/Espo/Core/Utils/Event/CrossInstanceEvent.php b/application/Espo/Core/Utils/Event/CrossInstanceEvent.php index 63ab30a0d36..a81edb4a828 100644 --- a/application/Espo/Core/Utils/Event/CrossInstanceEvent.php +++ b/application/Espo/Core/Utils/Event/CrossInstanceEvent.php @@ -33,7 +33,7 @@ interface CrossInstanceEvent extends Event { - public static function fromRaw(stdClass $payload): static; + public static function fromRaw(stdClass $payload): self; public function toRaw(): stdClass; } diff --git a/application/Espo/Core/Utils/Event/EventDispatcherTransport.php b/application/Espo/Core/Utils/Event/EventDispatcherTransport.php index 4516c6b988d..6242a1496fa 100644 --- a/application/Espo/Core/Utils/Event/EventDispatcherTransport.php +++ b/application/Espo/Core/Utils/Event/EventDispatcherTransport.php @@ -30,6 +30,7 @@ namespace Espo\Core\Utils\Event; use Closure; +use Espo\Core\Utils\Event\Exceptions\TransportNotConnected; /** * @since 10.1.0 @@ -43,5 +44,8 @@ public function subscribe(Closure $callback): void; public function dispatch(Envelope $envelope): void; - public function shouldReconnect(): bool; + /** + * @throws TransportNotConnected + */ + public function tick(): void; } diff --git a/application/Espo/Core/Utils/Event/Exceptions/TransportNotConnected.php b/application/Espo/Core/Utils/Event/Exceptions/TransportNotConnected.php new file mode 100644 index 00000000000..9fe8c30fd5d --- /dev/null +++ b/application/Espo/Core/Utils/Event/Exceptions/TransportNotConnected.php @@ -0,0 +1,35 @@ +. + * + * 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\Event\Exceptions; + +use Exception; + +class TransportNotConnected extends Exception +{} diff --git a/application/Espo/Tools/Currency/Events/CurrencyRateUpdate.php b/application/Espo/Tools/Currency/Events/CurrencyRateUpdate.php new file mode 100644 index 00000000000..6602789ee3e --- /dev/null +++ b/application/Espo/Tools/Currency/Events/CurrencyRateUpdate.php @@ -0,0 +1,49 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Tools\Currency\Events; + +use Espo\Core\Utils\Event\CrossInstanceEvent; +use stdClass; + +/** + * @since 10.1.0 + */ +readonly class CurrencyRateUpdate implements CrossInstanceEvent +{ + public function toRaw(): stdClass + { + return (object) []; + } + + public static function fromRaw(stdClass $payload): self + { + return new self(); + } +} diff --git a/application/Espo/Tools/Currency/SyncManager.php b/application/Espo/Tools/Currency/SyncManager.php index efa8d26b118..00a047d3020 100644 --- a/application/Espo/Tools/Currency/SyncManager.php +++ b/application/Espo/Tools/Currency/SyncManager.php @@ -33,11 +33,12 @@ use Espo\Core\Utils\Config\ConfigWriter; use Espo\Core\Utils\Config\SystemConfig; use Espo\Core\Utils\DataCache; -use Espo\Core\Utils\System\SystemState; +use Espo\Core\Utils\Event\EventDispatcher; use Espo\Entities\CurrencyRecord; use Espo\ORM\EntityManager; use Espo\ORM\Name\Attribute; use Espo\ORM\Query\UpdateBuilder; +use Espo\Tools\Currency\Events\CurrencyRateUpdate; use Espo\Tools\Currency\Exceptions\NotEnabled; /** @@ -55,7 +56,7 @@ public function __construct( private RateEntryProvider $rateEntryProvider, private DataCache $dataCache, private SystemConfig $systemConfig, - private SystemState $systemState, + private EventDispatcher $eventDispatcher, ) {} public function sync(): void @@ -129,7 +130,7 @@ public function refreshCache(): void $this->syncToConfigInTransaction(); }); - $this->getBumpVersionNumber(); + $this->dispatchUpdateEvent(); $this->clearCache(); } @@ -157,7 +158,7 @@ public function updateCode(string $code): void $this->configWriter->set('currencyRates', $rates); $this->configWriter->save(); - $this->getBumpVersionNumber(); + $this->dispatchUpdateEvent(); $this->clearCache(); } @@ -170,8 +171,8 @@ private function clearCache(): void $this->dataCache->clear($this->cacheKey); } - private function getBumpVersionNumber(): void + private function dispatchUpdateEvent(): void { - $this->systemState->bumpVersionNumber(); + $this->eventDispatcher->dispatch(new CurrencyRateUpdate()); } } diff --git a/tests/unit/Espo/Core/Utils/Event/TestCiEvent1.php b/tests/unit/Espo/Core/Utils/Event/TestCiEvent1.php index 9e89933c60e..88b47fb1d9a 100644 --- a/tests/unit/Espo/Core/Utils/Event/TestCiEvent1.php +++ b/tests/unit/Espo/Core/Utils/Event/TestCiEvent1.php @@ -38,7 +38,7 @@ public function __construct( public string $value, ) {} - public static function fromRaw(stdClass $payload): static + public static function fromRaw(stdClass $payload): self { return new self($payload->value ?? ''); } diff --git a/tests/unit/Espo/Core/Utils/Event/TestCiEvent2.php b/tests/unit/Espo/Core/Utils/Event/TestCiEvent2.php index 083164e09c3..40a2b58c0f1 100644 --- a/tests/unit/Espo/Core/Utils/Event/TestCiEvent2.php +++ b/tests/unit/Espo/Core/Utils/Event/TestCiEvent2.php @@ -34,7 +34,7 @@ class TestCiEvent2 implements CrossInstanceEvent { - public static function fromRaw(stdClass $payload): static + public static function fromRaw(stdClass $payload): self { return new self(); } diff --git a/tests/unit/Espo/Core/Utils/Event/TestTransport.php b/tests/unit/Espo/Core/Utils/Event/TestTransport.php index be217ad9d54..675115dfe43 100644 --- a/tests/unit/Espo/Core/Utils/Event/TestTransport.php +++ b/tests/unit/Espo/Core/Utils/Event/TestTransport.php @@ -52,10 +52,8 @@ public function subscribe(Closure $callback): void public function dispatch(Envelope $envelope): void {} - public function shouldReconnect(): bool - { - return true; - } + public function tick(): void + {} public function dispatchForTest(string $eventClassName, stdClass $payload): void { From aa4c4d532533ba1f4f6dca5000b22fdf2fbaba25 Mon Sep 17 00:00:00 2001 From: Yurii Date: Fri, 17 Jul 2026 16:16:10 +0300 Subject: [PATCH 25/87] Rate clear loaded map --- .../Events/{InvalidateUserCache.php => UserRoleUpdate.php} | 0 .../Acl/Events/PortalUserRoleUpdate.php} | 0 application/Espo/Tools/Currency/RateEntryProvider.php | 7 +++++++ 3 files changed, 7 insertions(+) rename application/Espo/Core/Acl/Events/{InvalidateUserCache.php => UserRoleUpdate.php} (100%) rename application/Espo/Core/{Acl/Events/InvalidatePortalUserCache.php => Portal/Acl/Events/PortalUserRoleUpdate.php} (100%) diff --git a/application/Espo/Core/Acl/Events/InvalidateUserCache.php b/application/Espo/Core/Acl/Events/UserRoleUpdate.php similarity index 100% rename from application/Espo/Core/Acl/Events/InvalidateUserCache.php rename to application/Espo/Core/Acl/Events/UserRoleUpdate.php diff --git a/application/Espo/Core/Acl/Events/InvalidatePortalUserCache.php b/application/Espo/Core/Portal/Acl/Events/PortalUserRoleUpdate.php similarity index 100% rename from application/Espo/Core/Acl/Events/InvalidatePortalUserCache.php rename to application/Espo/Core/Portal/Acl/Events/PortalUserRoleUpdate.php diff --git a/application/Espo/Tools/Currency/RateEntryProvider.php b/application/Espo/Tools/Currency/RateEntryProvider.php index 351fbb06afe..ac798cd6c5c 100644 --- a/application/Espo/Tools/Currency/RateEntryProvider.php +++ b/application/Espo/Tools/Currency/RateEntryProvider.php @@ -33,10 +33,12 @@ use Espo\Core\Currency\InternalRateEntryProvider; use Espo\Core\Field\Date; use Espo\Core\Utils\DateTime; +use Espo\Core\Utils\Event\EventDispatcher; use Espo\Entities\CurrencyRecord; use Espo\Entities\CurrencyRecordRate; use Espo\ORM\EntityManager; use Espo\ORM\Query\Part\Order; +use Espo\Tools\Currency\Events\CurrencyRateUpdate; use Espo\Tools\Currency\Exceptions\NotEnabled; use WeakMap; @@ -53,8 +55,13 @@ public function __construct( private EntityManager $entityManager, private DateTime $dateTime, private InternalRateEntryProvider $internalRateEntryProvider, + private EventDispatcher $eventDispatcher, ) { $this->map = new WeakMap(); + + $this->eventDispatcher->subscribe(CurrencyRateUpdate::class, function () { + $this->map = new WeakMap(); + }); } public function getCurrentRateEntry(CurrencyRecord $record): ?CurrencyRecordRate From 64edd68c7607dee6599e6a30264ed4f50390f3c6 Mon Sep 17 00:00:00 2001 From: Yurii Date: Fri, 17 Jul 2026 16:24:08 +0300 Subject: [PATCH 26/87] ACL events --- application/Espo/Core/Acl/Cache/Clearer.php | 8 +++---- .../Espo/Core/Acl/Events/UserRoleUpdate.php | 2 +- application/Espo/Core/AclManager.php | 17 ++++++++++++- .../Acl/Events/PortalUserRoleUpdate.php | 4 ++-- application/Espo/Core/Portal/AclManager.php | 24 ++++++++++++++++--- .../Espo/Core/Utils/Event/EventDispatcher.php | 14 +++++++---- tests/unit/Espo/Core/AclManagerTest.php | 16 +++++++------ 7 files changed, 63 insertions(+), 22 deletions(-) diff --git a/application/Espo/Core/Acl/Cache/Clearer.php b/application/Espo/Core/Acl/Cache/Clearer.php index 0e18376a275..a808dc58c1a 100644 --- a/application/Espo/Core/Acl/Cache/Clearer.php +++ b/application/Espo/Core/Acl/Cache/Clearer.php @@ -29,8 +29,8 @@ namespace Espo\Core\Acl\Cache; -use Espo\Core\Acl\Events\InvalidatePortalUserCache; -use Espo\Core\Acl\Events\InvalidateUserCache; +use Espo\Core\Portal\Acl\Events\PortalUserRoleUpdate; +use Espo\Core\Acl\Events\UserRoleUpdate; use Espo\Core\Utils\Event\EventDispatcher; use Espo\Core\Utils\File\Manager as FileManager; use Espo\Core\Utils\System\SystemState; @@ -80,7 +80,7 @@ public function clearForUser(User $user): void $this->fileManager->remove('data/cache/application/acl/' . $part); $this->fileManager->remove('data/cache/application/aclMap/' . $part); - $this->eventDispatcher->dispatch(new InvalidateUserCache($user->getId())); + $this->eventDispatcher->dispatch(new UserRoleUpdate($user->getId())); } private function clearForPortalUser(User $user): void @@ -96,7 +96,7 @@ private function clearForPortalUser(User $user): void $this->fileManager->remove('data/cache/application/aclPortal/' . $part); $this->fileManager->remove('data/cache/application/aclPortalMap/' . $part); - $event = new InvalidatePortalUserCache( + $event = new PortalUserRoleUpdate( userId: $user->getId(), portalId: $portal->getId(), ); diff --git a/application/Espo/Core/Acl/Events/UserRoleUpdate.php b/application/Espo/Core/Acl/Events/UserRoleUpdate.php index ee4d009fc2b..4275e88bedb 100644 --- a/application/Espo/Core/Acl/Events/UserRoleUpdate.php +++ b/application/Espo/Core/Acl/Events/UserRoleUpdate.php @@ -36,7 +36,7 @@ /** * @since 10.1.0 */ -readonly class InvalidateUserCache implements CrossInstanceEvent +readonly class UserRoleUpdate implements CrossInstanceEvent { public function __construct( public string $userId, diff --git a/application/Espo/Core/AclManager.php b/application/Espo/Core/AclManager.php index 832018e1a01..f132700f728 100644 --- a/application/Espo/Core/AclManager.php +++ b/application/Espo/Core/AclManager.php @@ -29,9 +29,11 @@ namespace Espo\Core; +use Espo\Core\Acl\Events\UserRoleUpdate; use Espo\Core\Acl\OwnershipSharedChecker; use Espo\Core\Acl\Permission; use Espo\Core\Name\Field; +use Espo\Core\Utils\Event\EventDispatcher; use Espo\ORM\Entity; use Espo\ORM\EntityManager; use Espo\Entities\User; @@ -119,12 +121,15 @@ public function __construct( MapFactory $mapFactory, protected GlobalRestriction $globalRestriction, protected OwnerUserFieldProvider $ownerUserFieldProvider, - protected EntityManager $entityManager + protected EntityManager $entityManager, + protected EventDispatcher $eventDispatcher, ) { $this->accessCheckerFactory = $accessCheckerFactory; $this->ownershipCheckerFactory = $ownershipCheckerFactory; $this->tableFactory = $tableFactory; $this->mapFactory = $mapFactory; + + $this->initEventHandling(); } /** @@ -782,4 +787,14 @@ public function checkUser(User $user, string $permission, User $target): bool return false; } + + protected function initEventHandling(): void + { + $this->eventDispatcher->subscribe(UserRoleUpdate::class, function (UserRoleUpdate $event) { + $userId = $event->userId; + + unset($this->tableHashMap[$userId]); + unset($this->mapHashMap[$userId]); + }); + } } diff --git a/application/Espo/Core/Portal/Acl/Events/PortalUserRoleUpdate.php b/application/Espo/Core/Portal/Acl/Events/PortalUserRoleUpdate.php index 535a88cb6e8..69b9250679e 100644 --- a/application/Espo/Core/Portal/Acl/Events/PortalUserRoleUpdate.php +++ b/application/Espo/Core/Portal/Acl/Events/PortalUserRoleUpdate.php @@ -27,7 +27,7 @@ * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. ************************************************************************/ -namespace Espo\Core\Acl\Events; +namespace Espo\Core\Portal\Acl\Events; use Espo\Core\Utils\Event\CrossInstanceEvent; use stdClass; @@ -36,7 +36,7 @@ /** * @since 10.1.0 */ -class InvalidatePortalUserCache implements CrossInstanceEvent +class PortalUserRoleUpdate implements CrossInstanceEvent { public function __construct( public string $userId, diff --git a/application/Espo/Core/Portal/AclManager.php b/application/Espo/Core/Portal/AclManager.php index e79a10a4a5f..3d9ab994d67 100644 --- a/application/Espo/Core/Portal/AclManager.php +++ b/application/Espo/Core/Portal/AclManager.php @@ -30,12 +30,12 @@ namespace Espo\Core\Portal; use Espo\Core\Acl\Permission; +use Espo\Core\Portal\Acl\Events\PortalUserRoleUpdate; +use Espo\Core\Utils\Event\EventDispatcher; use Espo\ORM\Entity; use Espo\ORM\EntityManager; - use Espo\Entities\Portal; use Espo\Entities\User; - use Espo\Core\Acl\GlobalRestriction; use Espo\Core\Acl\Map\Map; use Espo\Core\Acl\OwnerUserFieldProvider; @@ -70,7 +70,8 @@ public function __construct( GlobalRestriction $globalRestriction, OwnerUserFieldProvider $ownerUserFieldProvider, EntityManager $entityManager, - InternalAclManager $internalAclManager + InternalAclManager $internalAclManager, + EventDispatcher $eventDispatcher, ) { $this->accessCheckerFactory = $accessCheckerFactory; $this->ownershipCheckerFactory = $ownershipCheckerFactory; @@ -80,6 +81,9 @@ public function __construct( $this->ownerUserFieldProvider = $ownerUserFieldProvider; $this->entityManager = $entityManager; $this->internalAclManager = $internalAclManager; + $this->eventDispatcher = $eventDispatcher; + + $this->initEventHandling(); } public function setPortal(Portal $portal): void @@ -338,4 +342,18 @@ public function get(User $user, string $permission): string { return $this->getPermissionLevel($user, $permission); } + + protected function initEventHandling(): void + { + $this->eventDispatcher->subscribe(PortalUserRoleUpdate::class, function (PortalUserRoleUpdate $event) { + if ($this->portal?->getId() !== $event->portalId) { + return; + } + + $userId = $event->userId; + + unset($this->tableHashMap[$userId]); + unset($this->mapHashMap[$userId]); + }); + } } diff --git a/application/Espo/Core/Utils/Event/EventDispatcher.php b/application/Espo/Core/Utils/Event/EventDispatcher.php index ba8e52fd72f..2cff79e73bf 100644 --- a/application/Espo/Core/Utils/Event/EventDispatcher.php +++ b/application/Espo/Core/Utils/Event/EventDispatcher.php @@ -48,11 +48,14 @@ public function __construct( ) {} /** - * @param class-string $className - * @param Closure(Event, Context): void $callback + * @template T of Event + * @param class-string $className + * @param Closure(T, Context): void $callback */ public function subscribe(string $className, Closure $callback): void { + /** @var Closure(Event, Context): void $callback */ + $this->callbacks[$className] ??= []; $this->callbacks[$className][] = $callback; @@ -65,11 +68,14 @@ public function subscribe(string $className, Closure $callback): void } /** - * @param class-string $className - * @param Closure(Event, Context): void $callback + * @template T of Event + * @param class-string $className + * @param Closure(T, Context): void $callback */ public function unsubscribe(string $className, Closure $callback): void { + /** @var Closure(Event, Context): void $callback */ + if (!array_key_exists($className, $this->callbacks)) { return; } diff --git a/tests/unit/Espo/Core/AclManagerTest.php b/tests/unit/Espo/Core/AclManagerTest.php index bb18c3779ad..6f5184cffad 100644 --- a/tests/unit/Espo/Core/AclManagerTest.php +++ b/tests/unit/Espo/Core/AclManagerTest.php @@ -40,6 +40,7 @@ use Espo\Core\AclManager; use Espo\Core\ORM\EntityManager; +use Espo\Core\Utils\Event\EventDispatcher; use Espo\Entities\User; use PHPUnit\Framework\TestCase; @@ -66,13 +67,14 @@ protected function setUp(): void $globalRestriction = $this->createMock(GlobalRestriction::class); $this->aclManager = new AclManager( - $accessCheckerFactory, - $ownershipCheckerFactory, - $this->tableFactory, - $mapFactory, - $globalRestriction, - $this->createMock(OwnerUserFieldProvider::class), - $this->createMock(EntityManager::class) + accessCheckerFactory: $accessCheckerFactory, + ownershipCheckerFactory: $ownershipCheckerFactory, + tableFactory: $this->tableFactory, + mapFactory: $mapFactory, + globalRestriction: $globalRestriction, + ownerUserFieldProvider: $this->createMock(OwnerUserFieldProvider::class), + entityManager: $this->createMock(EntityManager::class), + eventDispatcher: $this->createMock(EventDispatcher::class), ); } From 15e9ccfdd91606ae09d1e8cc6b7d95cad9a4212d Mon Sep 17 00:00:00 2001 From: Yurii Date: Fri, 17 Jul 2026 16:25:25 +0300 Subject: [PATCH 27/87] Types --- application/Espo/Core/Acl/GlobalRestriction.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/application/Espo/Core/Acl/GlobalRestriction.php b/application/Espo/Core/Acl/GlobalRestriction.php index 473951a8d98..82490288466 100644 --- a/application/Espo/Core/Acl/GlobalRestriction.php +++ b/application/Espo/Core/Acl/GlobalRestriction.php @@ -42,15 +42,15 @@ class GlobalRestriction { /** Totally forbidden. */ - public const TYPE_FORBIDDEN = 'forbidden'; + public const string TYPE_FORBIDDEN = 'forbidden'; /** Reading forbidden, writing allowed. */ - public const TYPE_INTERNAL = 'internal'; + public const string TYPE_INTERNAL = 'internal'; /** Forbidden for non-admin users. */ - public const TYPE_ONLY_ADMIN = 'onlyAdmin'; + public const string TYPE_ONLY_ADMIN = 'onlyAdmin'; /** Read-only for all users. */ - public const TYPE_READ_ONLY = 'readOnly'; + public const string TYPE_READ_ONLY = 'readOnly'; /** Read-only for non-admin users. */ - public const TYPE_NON_ADMIN_READ_ONLY = 'nonAdminReadOnly'; + public const string TYPE_NON_ADMIN_READ_ONLY = 'nonAdminReadOnly'; /** * @var array From fb6ffd7f0dcb4bafb55b649b0328dfe844cd7b97 Mon Sep 17 00:00:00 2001 From: Yurii Date: Fri, 17 Jul 2026 16:48:01 +0300 Subject: [PATCH 28/87] Webhook update global event --- .../Espo/Core/Webhook/Events/UpdateGlobal.php | 46 +++++++++++++++++++ application/Espo/Core/Webhook/Manager.php | 16 +++++++ 2 files changed, 62 insertions(+) create mode 100644 application/Espo/Core/Webhook/Events/UpdateGlobal.php diff --git a/application/Espo/Core/Webhook/Events/UpdateGlobal.php b/application/Espo/Core/Webhook/Events/UpdateGlobal.php new file mode 100644 index 00000000000..206fe6a3818 --- /dev/null +++ b/application/Espo/Core/Webhook/Events/UpdateGlobal.php @@ -0,0 +1,46 @@ +. + * + * 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\Webhook\Events; + +use Espo\Core\Utils\Event\CrossInstanceEvent; +use stdClass; + +class UpdateGlobal implements CrossInstanceEvent +{ + public static function fromRaw(stdClass $payload): CrossInstanceEvent + { + return new self(); + } + + public function toRaw(): stdClass + { + return (object) []; + } +} diff --git a/application/Espo/Core/Webhook/Manager.php b/application/Espo/Core/Webhook/Manager.php index d78e32ea996..1830cb640aa 100644 --- a/application/Espo/Core/Webhook/Manager.php +++ b/application/Espo/Core/Webhook/Manager.php @@ -34,8 +34,11 @@ use Espo\Core\ORM\EntityManager; use Espo\Core\Utils\Cache\DataCacheAccess; use Espo\Core\Utils\Config; +use Espo\Core\Utils\Event\Context; +use Espo\Core\Utils\Event\EventDispatcher; use Espo\Core\Utils\FieldUtil; use Espo\Core\Utils\Log; +use Espo\Core\Webhook\Events\UpdateGlobal; use Espo\Entities\Webhook; use Espo\Entities\WebhookEventQueueItem; use Espo\ORM\Name\Attribute; @@ -69,12 +72,21 @@ public function __construct( private FieldUtil $fieldUtil, private Log $log, private DataCacheAccess $dataCacheAccess, + private EventDispatcher $eventDispatcher, ) { $this->dataCacheAccess->init( key: $this->cacheKey, loader: fn () => $this->buildData(), ); + + $this->eventDispatcher->subscribe(UpdateGlobal::class, function (UpdateGlobal $event, Context $context) { + if ($context->isLocal) { + return; + } + + $this->dataCacheAccess->reset(); + }); } /** @@ -114,6 +126,8 @@ public function addEvent(string $event): void $this->dataCacheAccess->set($data); $this->dataCacheAccess->store(); + + $this->eventDispatcher->dispatch(new UpdateGlobal()); } /** @@ -140,6 +154,8 @@ public function removeEvent(string $event): void $this->dataCacheAccess->set($data); $this->dataCacheAccess->store(); + + $this->eventDispatcher->dispatch(new UpdateGlobal()); } private function eventExists(string $event): bool From 247a715477d0e12e1c346a614fd89dfcb8957a7b Mon Sep 17 00:00:00 2001 From: Yurii Date: Fri, 17 Jul 2026 17:04:49 +0300 Subject: [PATCH 29/87] Ref, use data cache access --- .../Utils/Address/CountryDataProvider.php | 50 ++++++------------- 1 file changed, 14 insertions(+), 36 deletions(-) diff --git a/application/Espo/Core/Utils/Address/CountryDataProvider.php b/application/Espo/Core/Utils/Address/CountryDataProvider.php index 28eda25154a..201ad00eaaf 100644 --- a/application/Espo/Core/Utils/Address/CountryDataProvider.php +++ b/application/Espo/Core/Utils/Address/CountryDataProvider.php @@ -30,27 +30,29 @@ namespace Espo\Core\Utils\Address; use Espo\Core\Name\Field; -use Espo\Core\Utils\Config; -use Espo\Core\Utils\DataCache; +use Espo\Core\Utils\Cache\DataCacheAccess; 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; + private const string CACHE_KEY = 'addressCountryData'; + private const int LIMIT = 500; + /** + * @param DataCacheAccess $dataCacheAccess + */ public function __construct( - private DataCache $dataCache, private EntityManager $entityManager, - Config\SystemConfig $systemConfig, + private DataCacheAccess $dataCacheAccess, ) { - $this->useCache = $systemConfig->useCache(); + $dataCacheAccess->init( + key: self::CACHE_KEY, + loader: function () { + return $this->load(); + }, + ); } /** @@ -58,11 +60,7 @@ public function __construct( */ public function get(): array { - if ($this->data === null) { - $this->data = $this->load(); - } - - return $this->data; + return $this->dataCacheAccess->get(); } /** @@ -70,19 +68,6 @@ public function get(): array */ 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 = []; @@ -103,13 +88,6 @@ private function load(): array } } - if ($this->useCache) { - $this->dataCache->store(self::CACHE_KEY, [ - 'list' => $list, - 'preferredList' => $preferredList, - ]); - } - return [ 'list' => $list, 'preferredList' => $preferredList, From cfd776155d5650a68377192da4d76d4d6ed7f7ba Mon Sep 17 00:00:00 2001 From: Yurii Date: Fri, 17 Jul 2026 17:19:19 +0300 Subject: [PATCH 30/87] Pipeline data provider service --- application/Espo/Binding.php | 5 ++ .../metadata/app/containerServices.json | 3 ++ .../Espo/Tools/Pipeline/CacheClearer.php | 4 ++ .../Tools/Pipeline/Events/UpdateGlobal.php | 46 +++++++++++++++++++ .../Tools/Pipeline/PipelineDataProvider.php | 20 +++++++- 5 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 application/Espo/Tools/Pipeline/Events/UpdateGlobal.php diff --git a/application/Espo/Binding.php b/application/Espo/Binding.php index a6b2a788239..3c6ed8a5f6d 100644 --- a/application/Espo/Binding.php +++ b/application/Espo/Binding.php @@ -272,6 +272,11 @@ private function bindServices(Binder $binder): void 'Espo\\Core\\Utils\\Event\\EventDispatcherTransport', 'eventDispatcherTransport' ); + + $binder->bindService( + 'Espo\\Tools\\Pipeline\\PipelineDataProvider', + 'pipelineDataProvider' + ); } private function bindCore(Binder $binder): void diff --git a/application/Espo/Resources/metadata/app/containerServices.json b/application/Espo/Resources/metadata/app/containerServices.json index 172a4a2eaf3..2e9e64515f3 100644 --- a/application/Espo/Resources/metadata/app/containerServices.json +++ b/application/Espo/Resources/metadata/app/containerServices.json @@ -97,5 +97,8 @@ }, "eventDispatcherTransport": { "loaderClassName": "Espo\\Core\\Utils\\Event\\EventDispatcherTransportLoader" + }, + "pipelineDataProvider": { + "className": "Espo\\Tools\\Pipeline\\PipelineDataProvider" } } diff --git a/application/Espo/Tools/Pipeline/CacheClearer.php b/application/Espo/Tools/Pipeline/CacheClearer.php index 0e877c70a58..f95d94a81ae 100644 --- a/application/Espo/Tools/Pipeline/CacheClearer.php +++ b/application/Espo/Tools/Pipeline/CacheClearer.php @@ -30,7 +30,9 @@ namespace Espo\Tools\Pipeline; use Espo\Core\Utils\DataCache; +use Espo\Core\Utils\Event\EventDispatcher; use Espo\Core\WebSocket\Submission; +use Espo\Tools\Pipeline\Events\UpdateGlobal; class CacheClearer { @@ -39,11 +41,13 @@ class CacheClearer public function __construct( private DataCache $dataCache, private Submission $submission, + private EventDispatcher $eventDispatcher, ) {} public function clear(): void { $this->dataCache->clear(self::CACHE_KEY); + $this->eventDispatcher->dispatch(new UpdateGlobal()); $this->submission->submit('appParamsUpdate'); } } diff --git a/application/Espo/Tools/Pipeline/Events/UpdateGlobal.php b/application/Espo/Tools/Pipeline/Events/UpdateGlobal.php new file mode 100644 index 00000000000..9d47976188f --- /dev/null +++ b/application/Espo/Tools/Pipeline/Events/UpdateGlobal.php @@ -0,0 +1,46 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Tools\Pipeline\Events; + +use Espo\Core\Utils\Event\CrossInstanceEvent; +use stdClass; + +class UpdateGlobal implements CrossInstanceEvent +{ + public static function fromRaw(stdClass $payload): CrossInstanceEvent + { + return new self(); + } + + public function toRaw(): stdClass + { + return (object) []; + } +} diff --git a/application/Espo/Tools/Pipeline/PipelineDataProvider.php b/application/Espo/Tools/Pipeline/PipelineDataProvider.php index 19852ff7d25..22c86eaaf2f 100644 --- a/application/Espo/Tools/Pipeline/PipelineDataProvider.php +++ b/application/Espo/Tools/Pipeline/PipelineDataProvider.php @@ -31,6 +31,7 @@ use Espo\Core\Utils\Config\SystemConfig; use Espo\Core\Utils\DataCache; +use Espo\Core\Utils\Event\EventDispatcher; use Espo\Core\Utils\Log; use Espo\Core\Utils\Metadata; use Espo\Entities\Pipeline; @@ -38,6 +39,7 @@ use Espo\ORM\EntityManager; use Espo\Tools\Pipeline\Data\PipelineData; use Espo\Tools\Pipeline\Data\StageData; +use Espo\Tools\Pipeline\Events\UpdateGlobal; use stdClass; use Throwable; @@ -46,19 +48,33 @@ class PipelineDataProvider private const int LIMIT = 100; private const string CACHE_KEY = 'pipelines'; + /** + * @var ?array + */ + private ?array $data = null; + public function __construct( private Metadata $metadata, private EntityManager $entityManager, private SystemConfig $systemConfig, private DataCache $dataCache, private Log $log, - ) {} + EventDispatcher $eventDispatcher, + ) { + $eventDispatcher->subscribe(UpdateGlobal::class, function () { + $this->data = null; + }); + } /** * @return array */ public function get(): array { + if ($this->data !== null) { + return $this->data; + } + $data = null; $store = false; @@ -76,6 +92,8 @@ public function get(): array $this->storeCache($data); } + $this->data = $data; + return $data; } From efd710b2c0a4231a5fabb76448e7f41fbe34dbbe Mon Sep 17 00:00:00 2001 From: Yurii Date: Fri, 17 Jul 2026 19:04:56 +0300 Subject: [PATCH 31/87] Rename --- application/Espo/Binding.php | 4 ++-- application/Espo/Core/Job/Processing/Util/Ticker.php | 4 ++-- ...ventDispatcherTransport.php => BypassEventTransport.php} | 2 +- .../Espo/Core/Utils/Event/CrossInstanceEventDispatcher.php | 2 +- .../{EventDispatcherTransport.php => EventTransport.php} | 2 +- ...spatcherTransportLoader.php => EventTransportLoader.php} | 6 +++--- .../Espo/Resources/metadata/app/containerServices.json | 4 ++-- tests/unit/Espo/Core/Utils/Event/TestTransport.php | 4 ++-- 8 files changed, 14 insertions(+), 14 deletions(-) rename application/Espo/Core/Utils/Event/{BypassEventDispatcherTransport.php => BypassEventTransport.php} (95%) rename application/Espo/Core/Utils/Event/{EventDispatcherTransport.php => EventTransport.php} (98%) rename application/Espo/Core/Utils/Event/{EventDispatcherTransportLoader.php => EventTransportLoader.php} (89%) diff --git a/application/Espo/Binding.php b/application/Espo/Binding.php index 3c6ed8a5f6d..e307a18b402 100644 --- a/application/Espo/Binding.php +++ b/application/Espo/Binding.php @@ -269,8 +269,8 @@ private function bindServices(Binder $binder): void ); $binder->bindService( - 'Espo\\Core\\Utils\\Event\\EventDispatcherTransport', - 'eventDispatcherTransport' + 'Espo\\Core\\Utils\\Event\\EventTransport', + 'eventTransport' ); $binder->bindService( diff --git a/application/Espo/Core/Job/Processing/Util/Ticker.php b/application/Espo/Core/Job/Processing/Util/Ticker.php index 9b203b05560..5d4fe093ed8 100644 --- a/application/Espo/Core/Job/Processing/Util/Ticker.php +++ b/application/Espo/Core/Job/Processing/Util/Ticker.php @@ -30,13 +30,13 @@ namespace Espo\Core\Job\Processing\Util; use Espo\Core\Job\Processing\Exceptions\TickFailure; -use Espo\Core\Utils\Event\EventDispatcherTransport; +use Espo\Core\Utils\Event\EventTransport; use Espo\Core\Utils\Event\Exceptions\TransportNotConnected; class Ticker { public function __construct( - private EventDispatcherTransport $transport + private EventTransport $transport ) {} /** diff --git a/application/Espo/Core/Utils/Event/BypassEventDispatcherTransport.php b/application/Espo/Core/Utils/Event/BypassEventTransport.php similarity index 95% rename from application/Espo/Core/Utils/Event/BypassEventDispatcherTransport.php rename to application/Espo/Core/Utils/Event/BypassEventTransport.php index a1e12a30503..265229072eb 100644 --- a/application/Espo/Core/Utils/Event/BypassEventDispatcherTransport.php +++ b/application/Espo/Core/Utils/Event/BypassEventTransport.php @@ -31,7 +31,7 @@ use Closure; -class BypassEventDispatcherTransport implements EventDispatcherTransport +class BypassEventTransport implements EventTransport { public function subscribe(Closure $callback): void {} diff --git a/application/Espo/Core/Utils/Event/CrossInstanceEventDispatcher.php b/application/Espo/Core/Utils/Event/CrossInstanceEventDispatcher.php index 70e7cb5f9dc..39024e110a2 100644 --- a/application/Espo/Core/Utils/Event/CrossInstanceEventDispatcher.php +++ b/application/Espo/Core/Utils/Event/CrossInstanceEventDispatcher.php @@ -43,7 +43,7 @@ class CrossInstanceEventDispatcher private bool $isTransportSubscribed = false; public function __construct( - private EventDispatcherTransport $transport, + private EventTransport $transport, private OriginProvider $originProvider, ) {} diff --git a/application/Espo/Core/Utils/Event/EventDispatcherTransport.php b/application/Espo/Core/Utils/Event/EventTransport.php similarity index 98% rename from application/Espo/Core/Utils/Event/EventDispatcherTransport.php rename to application/Espo/Core/Utils/Event/EventTransport.php index 6242a1496fa..10e3618c5db 100644 --- a/application/Espo/Core/Utils/Event/EventDispatcherTransport.php +++ b/application/Espo/Core/Utils/Event/EventTransport.php @@ -35,7 +35,7 @@ /** * @since 10.1.0 */ -interface EventDispatcherTransport +interface EventTransport { /** * @param Closure(Envelope): void $callback diff --git a/application/Espo/Core/Utils/Event/EventDispatcherTransportLoader.php b/application/Espo/Core/Utils/Event/EventTransportLoader.php similarity index 89% rename from application/Espo/Core/Utils/Event/EventDispatcherTransportLoader.php rename to application/Espo/Core/Utils/Event/EventTransportLoader.php index 4e4e7fba3bb..61286d6577f 100644 --- a/application/Espo/Core/Utils/Event/EventDispatcherTransportLoader.php +++ b/application/Espo/Core/Utils/Event/EventTransportLoader.php @@ -35,14 +35,14 @@ /** * @noinspection PhpUnused */ -class EventDispatcherTransportLoader implements Loader +class EventTransportLoader implements Loader { public function __construct( private InjectableFactory $injectableFactory, ) {} - public function load(): EventDispatcherTransport + public function load(): EventTransport { - return $this->injectableFactory->create(BypassEventDispatcherTransport::class); + return $this->injectableFactory->create(BypassEventTransport::class); } } diff --git a/application/Espo/Resources/metadata/app/containerServices.json b/application/Espo/Resources/metadata/app/containerServices.json index 2e9e64515f3..1aac94eea09 100644 --- a/application/Espo/Resources/metadata/app/containerServices.json +++ b/application/Espo/Resources/metadata/app/containerServices.json @@ -95,8 +95,8 @@ "eventDispatcherConfiguration": { "className": "Espo\\Core\\Utils\\Event\\Configuration" }, - "eventDispatcherTransport": { - "loaderClassName": "Espo\\Core\\Utils\\Event\\EventDispatcherTransportLoader" + "eventTransport": { + "loaderClassName": "Espo\\Core\\Utils\\Event\\EventTransportLoader" }, "pipelineDataProvider": { "className": "Espo\\Tools\\Pipeline\\PipelineDataProvider" diff --git a/tests/unit/Espo/Core/Utils/Event/TestTransport.php b/tests/unit/Espo/Core/Utils/Event/TestTransport.php index 675115dfe43..2fb168152cb 100644 --- a/tests/unit/Espo/Core/Utils/Event/TestTransport.php +++ b/tests/unit/Espo/Core/Utils/Event/TestTransport.php @@ -31,10 +31,10 @@ use Closure; use Espo\Core\Utils\Event\Envelope; -use Espo\Core\Utils\Event\EventDispatcherTransport; +use Espo\Core\Utils\Event\EventTransport; use stdClass; -class TestTransport implements EventDispatcherTransport +class TestTransport implements EventTransport { /** * @var (Closure(Envelope): void)|null From d68c88bf725428e8d9aa90a60c1abb6a264106b8 Mon Sep 17 00:00:00 2001 From: Yurii Date: Fri, 17 Jul 2026 20:01:55 +0300 Subject: [PATCH 32/87] Rename --- application/Espo/Core/Utils/Event/BypassEventTransport.php | 2 +- .../Espo/Core/Utils/Event/CrossInstanceEventDispatcher.php | 2 +- application/Espo/Core/Utils/Event/EventTransport.php | 2 +- tests/unit/Espo/Core/Utils/Event/TestTransport.php | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/application/Espo/Core/Utils/Event/BypassEventTransport.php b/application/Espo/Core/Utils/Event/BypassEventTransport.php index 265229072eb..7ce1bcd1572 100644 --- a/application/Espo/Core/Utils/Event/BypassEventTransport.php +++ b/application/Espo/Core/Utils/Event/BypassEventTransport.php @@ -36,7 +36,7 @@ class BypassEventTransport implements EventTransport public function subscribe(Closure $callback): void {} - public function dispatch(Envelope $envelope): void + public function publish(Envelope $envelope): void {} public function tick(): void diff --git a/application/Espo/Core/Utils/Event/CrossInstanceEventDispatcher.php b/application/Espo/Core/Utils/Event/CrossInstanceEventDispatcher.php index 39024e110a2..3b81b03953d 100644 --- a/application/Espo/Core/Utils/Event/CrossInstanceEventDispatcher.php +++ b/application/Espo/Core/Utils/Event/CrossInstanceEventDispatcher.php @@ -88,7 +88,7 @@ public function dispatch(CrossInstanceEvent $event): void origin: $this->originProvider->get(), ); - $this->transport->dispatch($envelope); + $this->transport->publish($envelope); } private function ensureSubscribeTransport(): void diff --git a/application/Espo/Core/Utils/Event/EventTransport.php b/application/Espo/Core/Utils/Event/EventTransport.php index 10e3618c5db..cf83c345dd7 100644 --- a/application/Espo/Core/Utils/Event/EventTransport.php +++ b/application/Espo/Core/Utils/Event/EventTransport.php @@ -42,7 +42,7 @@ interface EventTransport */ public function subscribe(Closure $callback): void; - public function dispatch(Envelope $envelope): void; + public function publish(Envelope $envelope): void; /** * @throws TransportNotConnected diff --git a/tests/unit/Espo/Core/Utils/Event/TestTransport.php b/tests/unit/Espo/Core/Utils/Event/TestTransport.php index 2fb168152cb..50e840abe95 100644 --- a/tests/unit/Espo/Core/Utils/Event/TestTransport.php +++ b/tests/unit/Espo/Core/Utils/Event/TestTransport.php @@ -49,7 +49,7 @@ public function subscribe(Closure $callback): void $this->callback = $callback; } - public function dispatch(Envelope $envelope): void + public function publish(Envelope $envelope): void {} public function tick(): void From fa36aa933e8734ede700a5f7655ea00215f63fbc Mon Sep 17 00:00:00 2001 From: Yurii Date: Fri, 17 Jul 2026 20:01:01 +0300 Subject: [PATCH 33/87] Redis transport dev --- .../Team/ClearCacheAfterUnlink.php | 3 - .../Classes/RecordHooks/User/AfterUpdate.php | 13 +- .../Core/Utils/Event/EventTransportLoader.php | 7 + .../Core/Utils/Event/Redis/ClientProvider.php | 84 ++++++++ .../Utils/Event/Redis/RedisEventTransport.php | 197 ++++++++++++++++++ .../Espo/Resources/defaults/systemConfig.php | 2 + composer.json | 3 +- composer.lock | 65 +++++- 8 files changed, 357 insertions(+), 17 deletions(-) create mode 100644 application/Espo/Core/Utils/Event/Redis/ClientProvider.php create mode 100644 application/Espo/Core/Utils/Event/Redis/RedisEventTransport.php diff --git a/application/Espo/Classes/RecordHooks/Team/ClearCacheAfterUnlink.php b/application/Espo/Classes/RecordHooks/Team/ClearCacheAfterUnlink.php index 79155fdf9cd..8133609b2b0 100644 --- a/application/Espo/Classes/RecordHooks/Team/ClearCacheAfterUnlink.php +++ b/application/Espo/Classes/RecordHooks/Team/ClearCacheAfterUnlink.php @@ -30,7 +30,6 @@ namespace Espo\Classes\RecordHooks\Team; use Espo\Core\Acl\Cache\Clearer; -use Espo\Core\DataManager; use Espo\Core\Record\Hook\UnlinkHook; use Espo\Entities\Team; use Espo\Entities\User; @@ -43,7 +42,6 @@ class ClearCacheAfterUnlink implements UnlinkHook { public function __construct( private Clearer $clearer, - private DataManager $dataManager ) {} public function process(Entity $entity, string $link, Entity $foreignEntity): void @@ -53,6 +51,5 @@ public function process(Entity $entity, string $link, Entity $foreignEntity): vo } $this->clearer->clearForUser($foreignEntity); - $this->dataManager->updateCacheTimestamp(); } } diff --git a/application/Espo/Classes/RecordHooks/User/AfterUpdate.php b/application/Espo/Classes/RecordHooks/User/AfterUpdate.php index 3026bda6e15..64c33481ebb 100644 --- a/application/Espo/Classes/RecordHooks/User/AfterUpdate.php +++ b/application/Espo/Classes/RecordHooks/User/AfterUpdate.php @@ -30,7 +30,6 @@ namespace Espo\Classes\RecordHooks\User; use Espo\Core\Acl\Cache\Clearer; -use Espo\Core\DataManager; use Espo\Core\Record\Hook\SaveHook; use Espo\Modules\Crm\Entities\Contact; use Espo\ORM\Entity; @@ -46,7 +45,6 @@ class AfterUpdate implements SaveHook public function __construct( private EntityManager $entityManager, private Clearer $clearer, - private DataManager $dataManager ) {} public function process(Entity $entity): void @@ -61,21 +59,12 @@ private function processCache(User $entity): void $entity->isAttributeChanged('rolesIds') || $entity->isAttributeChanged('teamsIds') || $entity->isAttributeChanged('type') || - $entity->isAttributeChanged('portalRolesIds') || - $entity->isAttributeChanged('portalsIds') - ) { - $this->clearer->clearForUser($entity); - $this->dataManager->updateCacheTimestamp(); - } - - if ( $entity->isAttributeChanged('portalRolesIds') || $entity->isAttributeChanged('portalsIds') || $entity->isAttributeChanged('contactId') || $entity->isAttributeChanged('accountsIds') ) { - $this->clearer->clearForAllPortalUsers(); - $this->dataManager->updateCacheTimestamp(); + $this->clearer->clearForUser($entity); } } diff --git a/application/Espo/Core/Utils/Event/EventTransportLoader.php b/application/Espo/Core/Utils/Event/EventTransportLoader.php index 61286d6577f..0851b9ae132 100644 --- a/application/Espo/Core/Utils/Event/EventTransportLoader.php +++ b/application/Espo/Core/Utils/Event/EventTransportLoader.php @@ -31,6 +31,8 @@ use Espo\Core\Container\Loader; use Espo\Core\InjectableFactory; +use Espo\Core\Utils\Config; +use Espo\Core\Utils\Event\Redis\RedisEventTransport; /** * @noinspection PhpUnused @@ -39,10 +41,15 @@ class EventTransportLoader implements Loader { public function __construct( private InjectableFactory $injectableFactory, + private Config $config, ) {} public function load(): EventTransport { + if ($this->config->get('eventTransport') === 'Redis') { + return $this->injectableFactory->create(RedisEventTransport::class); + } + return $this->injectableFactory->create(BypassEventTransport::class); } } diff --git a/application/Espo/Core/Utils/Event/Redis/ClientProvider.php b/application/Espo/Core/Utils/Event/Redis/ClientProvider.php new file mode 100644 index 00000000000..708847ce472 --- /dev/null +++ b/application/Espo/Core/Utils/Event/Redis/ClientProvider.php @@ -0,0 +1,84 @@ +. + * + * 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\Event\Redis; + +use Espo\Core\Utils\Config; +use Predis\Client; +use Predis\CommunicationException; + +class ClientProvider +{ + private ?Client $client = null; + + public function __construct( + private Config $config, + ) {} + + public function get(): Client + { + if (!$this->client) { + $scheme = $this->config->get('redis.scheme') ?? null; + $port = $this->config->get('redis.port') ?? null; + $host = $this->config->get('redis.host') ?? null; + + $params = []; + + if ($scheme !== null) { + $params['scheme'] = $scheme; + } + + if ($host !== null) { + $params['host'] = $host; + } + + if ($port !== null) { + $params['port'] = $port; + } + + $this->client = new Client($params); + } + + return $this->client; + } + + /** + * @noinspection PhpRedundantCatchClauseInspection + */ + public function reconnect(): void + { + try { + $this->client?->disconnect(); + } catch (CommunicationException) {} + + $client = $this->get(); + + $client->connect(); + } +} diff --git a/application/Espo/Core/Utils/Event/Redis/RedisEventTransport.php b/application/Espo/Core/Utils/Event/Redis/RedisEventTransport.php new file mode 100644 index 00000000000..73d53d3da4a --- /dev/null +++ b/application/Espo/Core/Utils/Event/Redis/RedisEventTransport.php @@ -0,0 +1,197 @@ +. + * + * 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\Event\Redis; + +use Closure; +use Espo\Core\AclManager; +use Espo\Core\Utils\Event\CrossInstanceEvent; +use Espo\Core\Utils\Event\Envelope; +use Espo\Core\Utils\Event\EventTransport; +use Espo\Core\Utils\Json; +use Espo\Core\Utils\Log; +use LogicException; +use Predis\Connection\ConnectionException; +use RuntimeException; +use stdClass; +use Throwable; + +class RedisEventTransport implements EventTransport +{ + private const string STREAM_NAME = 'espocrm-events'; + + private const int COUNT = 50; + + /** + * @var ?(Closure(Envelope): void) $callback + */ + private ?Closure $callback = null; + + private ?string $lastId = null; + + public function __construct( + private ClientProvider $clientProvider, + private Log $log, + ) {} + + public function subscribe(Closure $callback): void + { + $this->callback = $callback; + } + + /** + * @noinspection PhpRedundantCatchClauseInspection + */ + public function publish(Envelope $envelope): void + { + $json = Json::encode([ + 'eventClassName' => $envelope->eventClassName, + 'payload' => $envelope->payload, + 'origin' => $envelope->origin, + ]); + + try { + $this->publishRaw($json); + } catch (ConnectionException $e) { + $this->log->info("Redis connection lost, reconnecting.", ['exception' => $e]); + + $this->clientProvider->reconnect(); + + $this->publishRaw($json); + } + } + + public function tick(): void + { + $client = $this->clientProvider->get(); + + if ($this->lastId === null) { + $response = $client->xread(1, null, [self::STREAM_NAME], '+'); + + $this->lastId = '0'; + + foreach (($response[self::STREAM_NAME] ?? []) as $item) { + [$messageId] = $this->getMessageData($item); + + $this->lastId = $messageId; + + break; + } + } + + $response = $client->xread(self::COUNT, null, [self::STREAM_NAME], $this->lastId); + + $jsonItems = []; + + foreach (($response[self::STREAM_NAME] ?? []) as $item) { + [$messageId, $json] = $this->getMessageData($item); + + $this->lastId = $messageId; + + $jsonItems[] = $json; + } + + if (!$this->callback) { + return; + } + + foreach ($jsonItems as $json) { + $this->processMessageItem($json); + } + } + + private function processMessageItem(string $json): void + { + if (!$this->callback) { + throw new LogicException(); + } + + $data = Json::decode($json); + + /** @var ?class-string $eventClassName */ + $eventClassName = $data->eventClassName ?? null; + $payload = $data->payload ?? null; + $origin = $data->origin ?? null; + + if (!is_string($eventClassName)) { + throw new RuntimeException(); + } + + if (!$payload instanceof stdClass) { + throw new RuntimeException(); + } + + if (!is_string($origin)) { + throw new RuntimeException(); + } + + $envelope = new Envelope( + eventClassName: $eventClassName, + payload: $payload, + origin: $origin, + ); + + try { + ($this->callback)($envelope); + } catch (Throwable $e) { + $this->log->error("Event callback error, {eventClassName}.", [ + 'exception' => $e, + 'eventClassName' => $eventClassName, + 'origin' => $origin, + ]); + } + } + + private function publishRaw(string $json): void + { + $client = $this->clientProvider->get(); + + $client->xadd(self::STREAM_NAME, ['data' => $json]); + } + + /** + * @param array $item + * @return array{string, string} + */ + private function getMessageData(mixed $item): array + { + $messageId = $item[0] ?? null; + $json = $item[1][1] ?? null; + + if ($messageId === null) { + throw new RuntimeException("Bad message data, no ID."); + } + + if (!is_string($json)) { + throw new RuntimeException("Bad message data."); + } + + return [$messageId, $json]; + } +} diff --git a/application/Espo/Resources/defaults/systemConfig.php b/application/Espo/Resources/defaults/systemConfig.php index 348b336bcc6..06b9d890b6c 100644 --- a/application/Espo/Resources/defaults/systemConfig.php +++ b/application/Espo/Resources/defaults/systemConfig.php @@ -121,6 +121,8 @@ 'cleanupAppLog', 'cleanupAppLogPeriod', 'rabbitMq', + 'redis', + 'eventTransport', ], 'adminItems' => [ 'devMode', diff --git a/composer.json b/composer.json index dce4186af13..69fddaf3648 100644 --- a/composer.json +++ b/composer.json @@ -57,7 +57,8 @@ "zbateson/mail-mime-parser": "^3.0", "guzzlehttp/guzzle": "^7.10", "devtheorem/php-handlebars": "^1.0", - "php-amqplib/php-amqplib": "^3.7" + "php-amqplib/php-amqplib": "^3.7", + "predis/predis": "^3.5" }, "require-dev": { "phpunit/phpunit": "^11.5", diff --git a/composer.lock b/composer.lock index 7a18c8f8e6a..a7d45b5359a 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "e8dd3b2f9887137751d0a383c4e27ce5", + "content-hash": "1ae60dc55489013607247b563b8935f3", "packages": [ { "name": "async-aws/core", @@ -4499,6 +4499,69 @@ }, "time": "2026-01-08T08:57:40+00:00" }, + { + "name": "predis/predis", + "version": "v3.5.1", + "source": { + "type": "git", + "url": "https://github.com/predis/predis.git", + "reference": "5c996db191ee2d9bafe651f454b1fca16754271b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/predis/predis/zipball/5c996db191ee2d9bafe651f454b1fca16754271b", + "reference": "5c996db191ee2d9bafe651f454b1fca16754271b", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0", + "psr/http-message": "^1.0|^2.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.3", + "phpstan/phpstan": "^1.9", + "phpunit/phpcov": "^6.0 || ^8.0", + "phpunit/phpunit": "^8.0 || ~9.4.4" + }, + "suggest": { + "ext-relay": "Faster connection with in-memory caching (>=0.6.2)" + }, + "type": "library", + "autoload": { + "psr-4": { + "Predis\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Till Krüss", + "homepage": "https://till.im", + "role": "Maintainer" + } + ], + "description": "A flexible and feature-complete Redis/Valkey client for PHP.", + "homepage": "http://github.com/predis/predis", + "keywords": [ + "nosql", + "predis", + "redis" + ], + "support": { + "issues": "https://github.com/predis/predis/issues", + "source": "https://github.com/predis/predis/tree/v3.5.1" + }, + "funding": [ + { + "url": "https://github.com/sponsors/tillkruss", + "type": "github" + } + ], + "time": "2026-06-11T16:56:53+00:00" + }, { "name": "psr/cache", "version": "3.0.0", From 5d8515f0b8e85e7c551b2c6df5da5aed0d6b245d Mon Sep 17 00:00:00 2001 From: Yurii Date: Sat, 18 Jul 2026 12:55:53 +0300 Subject: [PATCH 34/87] Cleanup --- application/Espo/Core/Acl/Cache/Clearer.php | 3 --- 1 file changed, 3 deletions(-) diff --git a/application/Espo/Core/Acl/Cache/Clearer.php b/application/Espo/Core/Acl/Cache/Clearer.php index a808dc58c1a..d819727246d 100644 --- a/application/Espo/Core/Acl/Cache/Clearer.php +++ b/application/Espo/Core/Acl/Cache/Clearer.php @@ -39,9 +39,6 @@ use Espo\ORM\EntityManager; use Espo\ORM\Name\Attribute; -/** - * @todo Clear loaded data in AclManager. - */ class Clearer { public function __construct( From de69965e98375f2f44d71f3678bd041b7d3bae47 Mon Sep 17 00:00:00 2001 From: Yurii Date: Sat, 18 Jul 2026 12:56:35 +0300 Subject: [PATCH 35/87] Rename redis stream --- application/Espo/Core/Utils/Event/Redis/RedisEventTransport.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/Espo/Core/Utils/Event/Redis/RedisEventTransport.php b/application/Espo/Core/Utils/Event/Redis/RedisEventTransport.php index 73d53d3da4a..ad340d1c384 100644 --- a/application/Espo/Core/Utils/Event/Redis/RedisEventTransport.php +++ b/application/Espo/Core/Utils/Event/Redis/RedisEventTransport.php @@ -44,7 +44,7 @@ class RedisEventTransport implements EventTransport { - private const string STREAM_NAME = 'espocrm-events'; + private const string STREAM_NAME = 'espocrm:events'; private const int COUNT = 50; From ad79c9da8b963966074a25d48bcfd54e279fdafd Mon Sep 17 00:00:00 2001 From: Yurii Date: Sat, 18 Jul 2026 12:59:20 +0300 Subject: [PATCH 36/87] Cleanup --- application/Espo/Core/Utils/Event/Redis/RedisEventTransport.php | 1 - 1 file changed, 1 deletion(-) diff --git a/application/Espo/Core/Utils/Event/Redis/RedisEventTransport.php b/application/Espo/Core/Utils/Event/Redis/RedisEventTransport.php index ad340d1c384..b1b4a5eda8f 100644 --- a/application/Espo/Core/Utils/Event/Redis/RedisEventTransport.php +++ b/application/Espo/Core/Utils/Event/Redis/RedisEventTransport.php @@ -30,7 +30,6 @@ namespace Espo\Core\Utils\Event\Redis; use Closure; -use Espo\Core\AclManager; use Espo\Core\Utils\Event\CrossInstanceEvent; use Espo\Core\Utils\Event\Envelope; use Espo\Core\Utils\Event\EventTransport; From a9fbe04a71a491455d5156d315e54753ad570cc8 Mon Sep 17 00:00:00 2001 From: Yurii Date: Sat, 18 Jul 2026 13:10:22 +0300 Subject: [PATCH 37/87] Return type --- application/Espo/Core/Loaders/HookDataProvider.php | 2 +- application/Espo/Core/Loaders/SystemState.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/application/Espo/Core/Loaders/HookDataProvider.php b/application/Espo/Core/Loaders/HookDataProvider.php index cd22bb23c5f..5cd5ffdb30c 100644 --- a/application/Espo/Core/Loaders/HookDataProvider.php +++ b/application/Espo/Core/Loaders/HookDataProvider.php @@ -42,7 +42,7 @@ public function __construct( private InjectableFactory $injectableFactory, ) {} - public function load() + public function load(): DataProvider { return $this->injectableFactory->create(DataProvider::class); } diff --git a/application/Espo/Core/Loaders/SystemState.php b/application/Espo/Core/Loaders/SystemState.php index e263346efac..2a3c332018a 100644 --- a/application/Espo/Core/Loaders/SystemState.php +++ b/application/Espo/Core/Loaders/SystemState.php @@ -42,7 +42,7 @@ public function __construct( private InjectableFactory $injectableFactory, ) {} - public function load() + public function load(): SystemStateService { return $this->injectableFactory->create(SystemStateService::class); } From 2df14637a402c9f2c0d7add7008ca88716d71ce5 Mon Sep 17 00:00:00 2001 From: Yurii Date: Sat, 18 Jul 2026 13:10:36 +0300 Subject: [PATCH 38/87] Loader return type --- application/Espo/Core/Container/Loader.php | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/application/Espo/Core/Container/Loader.php b/application/Espo/Core/Container/Loader.php index 64aea86d403..0e9b0898db5 100644 --- a/application/Espo/Core/Container/Loader.php +++ b/application/Espo/Core/Container/Loader.php @@ -34,8 +34,5 @@ */ interface Loader { - /** - * @return object - */ - public function load(); + public function load(): object; } From 7cdb2ac032bd4d5559b4a179b51a43aa0a6a1f15 Mon Sep 17 00:00:00 2001 From: Yurii Date: Sat, 18 Jul 2026 13:13:54 +0300 Subject: [PATCH 39/87] Comment --- application/Espo/Core/Loaders/Loader.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/application/Espo/Core/Loaders/Loader.php b/application/Espo/Core/Loaders/Loader.php index 88dad1468bc..6933c1bf136 100644 --- a/application/Espo/Core/Loaders/Loader.php +++ b/application/Espo/Core/Loaders/Loader.php @@ -33,7 +33,7 @@ /** * @deprecated Since v6.2.0. Use `Espo\Core\Container\Loader`. + * @todo Remove in v11.0. */ interface Loader extends BaseLoader -{ -} +{} From fee85d0807fcbb16cd6d9ba0b6e1d5c6bf615197 Mon Sep 17 00:00:00 2001 From: Yurii Date: Sat, 18 Jul 2026 13:58:05 +0300 Subject: [PATCH 40/87] Redis stream trim --- .../Espo/Core/Utils/Event/Redis/RedisEventTransport.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/application/Espo/Core/Utils/Event/Redis/RedisEventTransport.php b/application/Espo/Core/Utils/Event/Redis/RedisEventTransport.php index b1b4a5eda8f..81f7dfef9ee 100644 --- a/application/Espo/Core/Utils/Event/Redis/RedisEventTransport.php +++ b/application/Espo/Core/Utils/Event/Redis/RedisEventTransport.php @@ -46,6 +46,7 @@ class RedisEventTransport implements EventTransport private const string STREAM_NAME = 'espocrm:events'; private const int COUNT = 50; + private const int MAX_STREAM_LENGTH = 10000; /** * @var ?(Closure(Envelope): void) $callback @@ -171,7 +172,11 @@ private function publishRaw(string $json): void { $client = $this->clientProvider->get(); - $client->xadd(self::STREAM_NAME, ['data' => $json]); + $options = [ + 'trim' => ['MAXLEN', self::MAX_STREAM_LENGTH], + ]; + + $client->xadd(self::STREAM_NAME, ['data' => $json], '*', $options); } /** From d3f6bde18735e813b551fd7cbcc459d48eccdeb8 Mon Sep 17 00:00:00 2001 From: Yurii Date: Sat, 18 Jul 2026 14:05:04 +0300 Subject: [PATCH 41/87] Comment --- application/Espo/Core/Utils/Event/Redis/RedisEventTransport.php | 1 + 1 file changed, 1 insertion(+) diff --git a/application/Espo/Core/Utils/Event/Redis/RedisEventTransport.php b/application/Espo/Core/Utils/Event/Redis/RedisEventTransport.php index 81f7dfef9ee..1be3883814c 100644 --- a/application/Espo/Core/Utils/Event/Redis/RedisEventTransport.php +++ b/application/Espo/Core/Utils/Event/Redis/RedisEventTransport.php @@ -176,6 +176,7 @@ private function publishRaw(string $json): void 'trim' => ['MAXLEN', self::MAX_STREAM_LENGTH], ]; + // Magic method is used. $client->xadd(self::STREAM_NAME, ['data' => $json], '*', $options); } From 394e4d8209ca021eb4809f474a0e87182e8d8405 Mon Sep 17 00:00:00 2001 From: Yurii Date: Sat, 18 Jul 2026 14:45:22 +0300 Subject: [PATCH 42/87] RabbitMq nack not multiple --- .../Espo/Core/Job/Processing/RabbitMq/Consumer.php | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/application/Espo/Core/Job/Processing/RabbitMq/Consumer.php b/application/Espo/Core/Job/Processing/RabbitMq/Consumer.php index ab047f53354..79575564d64 100644 --- a/application/Espo/Core/Job/Processing/RabbitMq/Consumer.php +++ b/application/Espo/Core/Job/Processing/RabbitMq/Consumer.php @@ -159,11 +159,6 @@ private function getJobId(AMQPMessage $message): string return $id; } - private function nack(AMQPMessage $message): void - { - $message->nack(false, true); - } - private function setupConsume(AMQPChannel $channel, string $queue): void { $channel->basic_consume( @@ -174,7 +169,7 @@ private function setupConsume(AMQPChannel $channel, string $queue): void } catch (Throwable $e) { $this->log->error("Worker: Could not get job ID.", ['exception' => $e]); - $this->nack($message); + $message->nack(); return; } @@ -189,7 +184,7 @@ private function setupConsume(AMQPChannel $channel, string $queue): void 'id' => $id, ]); - $this->nack($message); + $message->nack(); return; } From 04c85a2fdfb69ad07748bd2e2aa2accf4e1832a4 Mon Sep 17 00:00:00 2001 From: Yurii Date: Sun, 19 Jul 2026 12:57:00 +0300 Subject: [PATCH 43/87] Action handler error --- client/src/utils.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/client/src/utils.ts b/client/src/utils.ts index 4314b79840e..78ccf4459d8 100644 --- a/client/src/utils.ts +++ b/client/src/utils.ts @@ -196,9 +196,13 @@ const Utils = { fired = true; Espo.loader.require(handler, Handler => { - const handler = new Handler(view); + const handlerInstance = new Handler(view); - handler[method].call(handler, data, event); + if (!(method in handlerInstance)) { + throw new Error(`No method '${method}' in action handler '${handler}'.`); + } + + handlerInstance[method].call(handlerInstance, data, event); }); } else if ( // @ts-ignore From be3aeb275f1db1548298a3af20b5f8867c4bf694 Mon Sep 17 00:00:00 2001 From: Yurii Date: Sun, 19 Jul 2026 16:52:41 +0300 Subject: [PATCH 44/87] Ref --- application/Espo/Core/Binding/Binder.php | 26 +--- application/Espo/Core/Binding/Binding.php | 24 ++-- .../Espo/Core/Binding/BindingContainer.php | 115 ++++++++++-------- .../Espo/Core/Binding/ContextualBinder.php | 40 ++---- application/Espo/Core/InjectableFactory.php | 35 +++--- 5 files changed, 111 insertions(+), 129 deletions(-) diff --git a/application/Espo/Core/Binding/Binder.php b/application/Espo/Core/Binding/Binder.php index 34d2dd27c50..a5d41469c93 100644 --- a/application/Espo/Core/Binding/Binder.php +++ b/application/Espo/Core/Binding/Binder.php @@ -50,10 +50,7 @@ public function bindImplementation(string|NamedClassKey $key, string $implementa $key = self::keyToString($key); $this->validateBindingKey($key); - $this->data->addGlobal( - $key, - Binding::createFromImplementationClassName($implementationClassName) - ); + $this->data->addGlobal($key, Binding::createFromImplementationClassName($implementationClassName)); return $this; } @@ -69,10 +66,7 @@ public function bindService(string|NamedClassKey $key, string $serviceName): sel $key = self::keyToString($key); $this->validateBindingKey($key); - $this->data->addGlobal( - $key, - Binding::createFromServiceName($serviceName) - ); + $this->data->addGlobal($key, Binding::createFromServiceName($serviceName)); return $this; } @@ -90,10 +84,7 @@ public function bindCallback(string|NamedClassKey $key, Closure $callback): self $key = self::keyToString($key); $this->validateBindingKey($key); - $this->data->addGlobal( - $key, - Binding::createFromCallback($callback) - ); + $this->data->addGlobal($key, Binding::createFromCallback($callback)); return $this; } @@ -104,17 +95,13 @@ public function bindCallback(string|NamedClassKey $key, Closure $callback): self * @template T of object * @param class-string|NamedClassKey $key An interface or interface with a parameter name. * @param T $instance An instance. - * @noinspection PhpDocSignatureInspection */ public function bindInstance(string|NamedClassKey $key, object $instance): self { $key = self::keyToString($key); $this->validateBindingKey($key); - $this->data->addGlobal( - $key, - Binding::createFromValue($instance) - ); + $this->data->addGlobal($key, Binding::createFromValue($instance)); return $this; } @@ -131,10 +118,7 @@ public function bindFactory(string|NamedClassKey $key, string $factoryClassName) $key = self::keyToString($key); $this->validateBindingKey($key); - $this->data->addGlobal( - $key, - Binding::createFromFactoryClassName($factoryClassName) - ); + $this->data->addGlobal($key, Binding::createFromFactoryClassName($factoryClassName)); return $this; } diff --git a/application/Espo/Core/Binding/Binding.php b/application/Espo/Core/Binding/Binding.php index b6f4dfdc530..7e8175fc3fa 100644 --- a/application/Espo/Core/Binding/Binding.php +++ b/application/Espo/Core/Binding/Binding.php @@ -33,20 +33,17 @@ class Binding { - public const IMPLEMENTATION_CLASS_NAME = 1; - public const CONTAINER_SERVICE = 2; - public const VALUE = 3; - public const CALLBACK = 4; - public const FACTORY_CLASS_NAME = 5; + public const int IMPLEMENTATION_CLASS_NAME = 1; + public const int CONTAINER_SERVICE = 2; + public const int VALUE = 3; + public const int CALLBACK = 4; + public const int FACTORY_CLASS_NAME = 5; private int $type; - /** @var mixed */ - private $value; - /** - * @param mixed $value - */ - private function __construct(int $type, $value) + private mixed $value; + + private function __construct(int $type, mixed $value) { $this->type = $type; $this->value = $value; @@ -57,10 +54,7 @@ public function getType(): int return $this->type; } - /** - * @return mixed - */ - public function getValue() + public function getValue(): mixed { return $this->value; } diff --git a/application/Espo/Core/Binding/BindingContainer.php b/application/Espo/Core/Binding/BindingContainer.php index 7ec045f2b97..96af818b2c0 100644 --- a/application/Espo/Core/Binding/BindingContainer.php +++ b/application/Espo/Core/Binding/BindingContainer.php @@ -67,8 +67,7 @@ public function getByParam(?ReflectionClass $class, ReflectionParameter $param): throw new LogicException("Cannot get not existing binding."); } - /** @var Binding */ - return $this->getInternal($class, $param); + return $this->getInternal($class, $param) ?? throw new LogicException(); } /** @@ -104,74 +103,94 @@ public function getByInterface(string $interfaceName): Binding */ private function getInternal(?ReflectionClass $class, ReflectionParameter $param): ?Binding { - $className = null; - - $key = null; - if ($class) { - $className = $class->getName(); + $binding = $this->getInternalContextualNamed($class, $param); - $key = '$' . $param->getName(); + if ($binding) { + return $binding; + } } - $type = $param->getType(); + $paramClassName = $this->getClassNameFromParameterType($param->getType()); - if ( - $className && - $key && - $this->data->hasContext($className, $key) - ) { - $binding = $this->data->getContext($className, $key); + if ($paramClassName === null) { + return null; + } - $notMatching = - $type instanceof ReflectionNamedType && - !$type->isBuiltin() && - $binding->getType() === Binding::VALUE && - is_scalar($binding->getValue()); + $keyWithName = $paramClassName . ' $' . $param->getName(); - if (!$notMatching) { - return $binding; - } + $binding = $this->getInternalByClassNameKey($class?->getName(), $keyWithName); + + if ($binding) { + return $binding; } - $dependencyClassName = null; + $key = $paramClassName; - if ( - $type instanceof ReflectionNamedType && - !$type->isBuiltin() - ) { - $dependencyClassName = $type->getName(); + $binding = $this->getInternalByClassNameKey($class?->getName(), $key); + + if ($binding) { + return $binding; } - $key = null; - $keyWithParamName = null; + return null; + } - if ($dependencyClassName) { - $key = $dependencyClassName; + /** + * @param ReflectionClass $class + */ + private function getInternalContextualNamed(ReflectionClass $class, ReflectionParameter $param): ?Binding + { + $key = '$' . $param->getName(); - $keyWithParamName = $key . ' $' . $param->getName(); + if (!$this->data->hasContext($class->getName(), $key)) { + return null; } - if ($keyWithParamName) { - if ($className && $this->data->hasContext($className, $keyWithParamName)) { - return $this->data->getContext($className, $keyWithParamName); - } + $type = $param->getType(); - if ($this->data->hasGlobal($keyWithParamName)) { - return $this->data->getGlobal($keyWithParamName); - } + $binding = $this->data->getContext($class->getName(), $key); + + $notMatching = + $type instanceof ReflectionNamedType && + !$type->isBuiltin() && + $binding->getType() === Binding::VALUE && + is_scalar($binding->getValue()); + + if ($notMatching) { + return null; } - if ($key) { - if ($className && $this->data->hasContext($className, $key)) { - return $this->data->getContext($className, $key); - } + return $binding; + } - if ($this->data->hasGlobal($key)) { - return $this->data->getGlobal($key); - } + /** + * @param ?class-string $className + */ + private function getInternalByClassNameKey(?string $className, string $key): ?Binding + { + if ($className && $this->data->hasContext($className, $key)) { + return $this->data->getContext($className, $key); + } + + if ($this->data->hasGlobal($key)) { + return $this->data->getGlobal($key); } return null; } + + private function getClassNameFromParameterType(mixed $type): ?string + { + $dependencyClassName = null; + + if ( + $type instanceof ReflectionNamedType && + !$type->isBuiltin() + ) { + $dependencyClassName = $type->getName(); + } + + return $dependencyClassName; + } } diff --git a/application/Espo/Core/Binding/ContextualBinder.php b/application/Espo/Core/Binding/ContextualBinder.php index 335edf8ce7d..de46cb341d7 100644 --- a/application/Espo/Core/Binding/ContextualBinder.php +++ b/application/Espo/Core/Binding/ContextualBinder.php @@ -37,6 +37,7 @@ class ContextualBinder { private BindingData $data; + /** @var class-string */ private string $className; @@ -61,11 +62,9 @@ public function bindImplementation(string|NamedClassKey $key, string $implementa $key = self::keyToString($key); $this->validateBindingKeyNoParameterName($key); - $this->data->addContext( - $this->className, - $key, - Binding::createFromImplementationClassName($implementationClassName) - ); + $binding = Binding::createFromImplementationClassName($implementationClassName); + + $this->data->addContext($this->className, $key, $binding); return $this; } @@ -82,11 +81,7 @@ public function bindService(string|NamedClassKey $key, string $serviceName): sel $key = self::keyToString($key); $this->validateBindingKeyNoParameterName($key); - $this->data->addContext( - $this->className, - $key, - Binding::createFromServiceName($serviceName) - ); + $this->data->addContext($this->className, $key, Binding::createFromServiceName($serviceName)); return $this; } @@ -102,11 +97,7 @@ public function bindValue(string|NamedKey|NamedClassKey $key, $value): self $key = self::keyToString($key); $this->validateBindingKeyParameterName($key); - $this->data->addContext( - $this->className, - $key, - Binding::createFromValue($value) - ); + $this->data->addContext($this->className, $key, Binding::createFromValue($value)); return $this; } @@ -117,18 +108,13 @@ public function bindValue(string|NamedKey|NamedClassKey $key, $value): self * @template T of object * @param class-string|NamedClassKey $key An interface or interface with a parameter name. * @param T $instance An instance. - * @noinspection PhpDocSignatureInspection */ public function bindInstance(string|NamedClassKey $key, object $instance): self { $key = self::keyToString($key); $this->validateBindingKeyNoParameterName($key); - $this->data->addContext( - $this->className, - $key, - Binding::createFromValue($instance) - ); + $this->data->addContext($this->className, $key, Binding::createFromValue($instance)); return $this; } @@ -145,11 +131,7 @@ public function bindCallback(string|NamedClassKey|NamedKey $key, Closure $callba $key = self::keyToString($key); $this->validateBinding($key); - $this->data->addContext( - $this->className, - $key, - Binding::createFromCallback($callback) - ); + $this->data->addContext($this->className, $key, Binding::createFromCallback($callback)); return $this; } @@ -166,11 +148,7 @@ public function bindFactory(string|NamedClassKey $key, string $factoryClassName) $key = self::keyToString($key); $this->validateBindingKeyNoParameterName($key); - $this->data->addContext( - $this->className, - $key, - Binding::createFromFactoryClassName($factoryClassName) - ); + $this->data->addContext($this->className, $key, Binding::createFromFactoryClassName($factoryClassName)); return $this; } diff --git a/application/Espo/Core/InjectableFactory.php b/application/Espo/Core/InjectableFactory.php index 6cc09787a18..29050f9e928 100644 --- a/application/Espo/Core/InjectableFactory.php +++ b/application/Espo/Core/InjectableFactory.php @@ -34,8 +34,8 @@ use Espo\Core\Binding\BindingContainer; use Espo\Core\Binding\Binding; use Espo\Core\Binding\Factory; - use ReflectionClass; +use ReflectionException; use ReflectionParameter; use ReflectionFunction; use ReflectionNamedType; @@ -114,6 +114,7 @@ public function createResolved(string $interfaceName, ?BindingContainer $binding null; if (!$binding) { + /** @noinspection PhpUnhandledExceptionInspection */ $class = new ReflectionClass($interfaceName); if ($class->isInterface()) { @@ -145,6 +146,7 @@ public function createResolved(string $interfaceName, ?BindingContainer $binding throw new RuntimeException("Class `$interfaceName` resolved to another type."); } + /** @noinspection PhpIncompatibleReturnTypeInspection */ return $obj; } @@ -157,18 +159,22 @@ public function createResolved(string $interfaceName, ?BindingContainer $binding private function createInternal( string $className, ?array $with = null, - ?BindingContainer $bindingContainer = null + ?BindingContainer $bindingContainer = null, ): object { if (!class_exists($className)) { - throw new RuntimeException("InjectableFactory: Class '$className' does not exist."); + throw new RuntimeException("Class '$className' does not exist."); } $class = new ReflectionClass($className); $injectionList = $this->getConstructorInjectionList($class, $with, $bindingContainer); - $obj = $class->newInstanceArgs($injectionList); + try { + $obj = $class->newInstanceArgs($injectionList); + } catch (ReflectionException $e) { + throw new RuntimeException("Reflection error.", previous: $e); + } $this->applyAwareInjections($class, $obj); @@ -178,7 +184,7 @@ private function createInternal( /** * @param ReflectionClass $class * @param ?array $with - * @return mixed[] + * @return array[] */ private function getConstructorInjectionList( ReflectionClass $class, @@ -225,6 +231,7 @@ private function getMethodParamInjection( $type = $param->getType(); + /** @noinspection PhpConditionCheckedByNextConditionInspection */ if ( $type && $type instanceof ReflectionNamedType && @@ -290,17 +297,21 @@ class_exists($badClassName); } /** - * @return mixed[] + * @return array[] */ private function getCallbackInjectionList(callable $callback): array { $injectionList = []; if (!$callback instanceof Closure) { - $callback = Closure::fromCallable($callback); + $callback = $callback(...); } - $function = new ReflectionFunction($callback); + try { + $function = new ReflectionFunction($callback); + } catch (ReflectionException $e) { + throw new RuntimeException("Reflection error.", previous: $e); + } foreach ($function->getParameters() as $param) { $injectionList[] = $this->getMethodParamInjection(null, $param); @@ -372,9 +383,8 @@ private function areDependencyClassesMatching( /** * @param ReflectionClass $class - * @param string[] $ignoreList */ - private function applyAwareInjections(ReflectionClass $class, object $obj, array $ignoreList = []): void + private function applyAwareInjections(ReflectionClass $class, object $obj): void { foreach ($class->getInterfaces() as $interface) { $interfaceName = $interface->getShortName(); @@ -385,10 +395,6 @@ private function applyAwareInjections(ReflectionClass $class, object $obj, array $name = lcfirst(substr($interfaceName, 0, -5)); - if (in_array($name, $ignoreList)) { - continue; - } - if (!$this->classHasDependencySetter($class, $name, true)) { continue; } @@ -432,6 +438,7 @@ private function classHasDependencySetter( $type = $params[0]->getType(); + /** @noinspection PhpConditionCheckedByNextConditionInspection */ if ( $type && $type instanceof ReflectionNamedType && From 456402847e55a7d34d251321facf80bfaa700be0 Mon Sep 17 00:00:00 2001 From: Yurii Date: Sun, 19 Jul 2026 19:18:59 +0300 Subject: [PATCH 45/87] Binding with qualification --- .../Espo/Core/Binding/Attributes/Qualify.php | 45 +++++++ application/Espo/Core/Binding/Binder.php | 39 ++++-- .../Espo/Core/Binding/BindingContainer.php | 26 ++++ .../Core/Binding/BindingContainerBuilder.php | 27 ++-- .../Espo/Core/Binding/ContextualBinder.php | 66 +++++---- .../Core/Binding/Key/QualifiedClassKey.php | 69 ++++++++++ .../Espo/Core/Binding/BindingTest.php | 63 ++++++++- .../testClasses/Binding/SomeClass.php | 2 +- .../Binding/SomeClassRequiringService.php | 11 +- .../Binding/SomeClassRequiringServiceAlt.php | 46 +++++++ .../testClasses/Binding/SomeService.php | 6 +- .../Core/Binding/BindingContainerTest.php | 127 +++++++++++++----- .../testClasses/Core/Binding/SomeClass0.php | 8 +- .../testClasses/Core/Binding/SomeClass1.php | 8 +- .../Core/Binding/SomeClass1Alt.php | 36 +++++ .../testClasses/Core/Binding/SomeClass2.php | 4 +- .../Core/Binding/SomeClass2Alt.php | 33 +++++ .../Core/Binding/SomeInterface2.php | 4 +- 18 files changed, 503 insertions(+), 117 deletions(-) create mode 100644 application/Espo/Core/Binding/Attributes/Qualify.php create mode 100644 application/Espo/Core/Binding/Key/QualifiedClassKey.php create mode 100644 tests/integration/testClasses/Binding/SomeClassRequiringServiceAlt.php create mode 100644 tests/unit/testClasses/Core/Binding/SomeClass1Alt.php create mode 100644 tests/unit/testClasses/Core/Binding/SomeClass2Alt.php diff --git a/application/Espo/Core/Binding/Attributes/Qualify.php b/application/Espo/Core/Binding/Attributes/Qualify.php new file mode 100644 index 00000000000..ac113a96efb --- /dev/null +++ b/application/Espo/Core/Binding/Attributes/Qualify.php @@ -0,0 +1,45 @@ +. + * + * 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\Binding\Attributes; + +use Attribute; + +/** + * Assigns a qualifier to a constructor parameter. + * + * @since 10.1.0 + */ +#[Attribute(Attribute::TARGET_PARAMETER)] +readonly class Qualify +{ + public function __construct( + public string $qualifier, + ) {} +} diff --git a/application/Espo/Core/Binding/Binder.php b/application/Espo/Core/Binding/Binder.php index a5d41469c93..d7d203b6494 100644 --- a/application/Espo/Core/Binding/Binder.php +++ b/application/Espo/Core/Binding/Binder.php @@ -30,6 +30,7 @@ namespace Espo\Core\Binding; use Espo\Core\Binding\Key\NamedClassKey; +use Espo\Core\Binding\Key\QualifiedClassKey; use LogicException; use Closure; @@ -42,11 +43,15 @@ public function __construct(private BindingData $data) * Bind an interface to an implementation. * * @template T of object - * @param class-string|NamedClassKey $key An interface or interface with a parameter name. + * @param class-string|NamedClassKey|QualifiedClassKey $key + * An interface, an interface with a parameter name or an interface with a qualifier. * @param class-string $implementationClassName An implementation class name. */ - public function bindImplementation(string|NamedClassKey $key, string $implementationClassName): self - { + public function bindImplementation( + string|NamedClassKey|QualifiedClassKey $key, + string $implementationClassName, + ): self { + $key = self::keyToString($key); $this->validateBindingKey($key); @@ -58,10 +63,11 @@ public function bindImplementation(string|NamedClassKey $key, string $implementa /** * Bind an interface to a specific service. * - * @param class-string|NamedClassKey $key An interface or interface with a parameter name. + * @param class-string|NamedClassKey|QualifiedClassKey $key + * An interface, an interface with a parameter name or an interface with a qualifier. * @param string $serviceName A service name. */ - public function bindService(string|NamedClassKey $key, string $serviceName): self + public function bindService(string|NamedClassKey|QualifiedClassKey $key, string $serviceName): self { $key = self::keyToString($key); $this->validateBindingKey($key); @@ -75,11 +81,12 @@ public function bindService(string|NamedClassKey $key, string $serviceName): sel * Bind an interface to a callback. * * @template T of object - * @param class-string|NamedClassKey $key An interface or interface with a parameter name. + * @param class-string|NamedClassKey|QualifiedClassKey $key + * An interface, an interface with a parameter name or an interface with a qualifier. * @param Closure $callback A callback that will resolve a dependency. * @todo Change to Closure(...): T Once https://github.com/phpstan/phpstan/issues/8214 is implemented. */ - public function bindCallback(string|NamedClassKey $key, Closure $callback): self + public function bindCallback(string|NamedClassKey|QualifiedClassKey $key, Closure $callback): self { $key = self::keyToString($key); $this->validateBindingKey($key); @@ -93,10 +100,11 @@ public function bindCallback(string|NamedClassKey $key, Closure $callback): self * Bind an interface to a specific instance. * * @template T of object - * @param class-string|NamedClassKey $key An interface or interface with a parameter name. + * @param class-string|NamedClassKey|QualifiedClassKey $key + * An interface, an interface with a parameter name or an interface with a qualifier. * @param T $instance An instance. */ - public function bindInstance(string|NamedClassKey $key, object $instance): self + public function bindInstance(string|NamedClassKey|QualifiedClassKey $key, object $instance): self { $key = self::keyToString($key); $this->validateBindingKey($key); @@ -110,10 +118,11 @@ public function bindInstance(string|NamedClassKey $key, object $instance): self * Bind an interface to a factory. * * @template T of object - * @param class-string|NamedClassKey $key An interface or interface with a parameter name. + * @param class-string|NamedClassKey|QualifiedClassKey $key + * An interface, an interface with a parameter name or an interface with a qualifier. * @param class-string> $factoryClassName A factory class name. */ - public function bindFactory(string|NamedClassKey $key, string $factoryClassName): self + public function bindFactory(string|NamedClassKey|QualifiedClassKey $key, string $factoryClassName): self { $key = self::keyToString($key); $this->validateBindingKey($key); @@ -149,9 +158,9 @@ public function for(string $className): ContextualBinder } /** - * @param string|NamedClassKey $key + * @param string|NamedClassKey|QualifiedClassKey $key */ - private static function keyToString(string|NamedClassKey $key): string + private static function keyToString(string|NamedClassKey|QualifiedClassKey $key): string { return is_string($key) ? $key : $key->toString(); } @@ -165,5 +174,9 @@ private function validateBindingKey(string $key): void if ($key[0] === '$') { throw new LogicException("Can't binding a parameter name w/o an interface globally."); } + + if ($key[0] === '#') { + throw new LogicException("Can't binding a qualification name w/o an interface globally."); + } } } diff --git a/application/Espo/Core/Binding/BindingContainer.php b/application/Espo/Core/Binding/BindingContainer.php index 96af818b2c0..f8be6bdf46b 100644 --- a/application/Espo/Core/Binding/BindingContainer.php +++ b/application/Espo/Core/Binding/BindingContainer.php @@ -29,6 +29,7 @@ namespace Espo\Core\Binding; +use Espo\Core\Binding\Attributes\Qualify; use ReflectionClass; use ReflectionParameter; use ReflectionNamedType; @@ -117,6 +118,18 @@ private function getInternal(?ReflectionClass $class, ReflectionParameter $param return null; } + $qualifiedName = $this->getQualifiedName($param); + + if ($qualifiedName) { + $keyQualified = $paramClassName . ' #' . $qualifiedName; + + $binding = $this->getInternalByClassNameKey($class?->getName(), $keyQualified); + + if ($binding) { + return $binding; + } + } + $keyWithName = $paramClassName . ' $' . $param->getName(); $binding = $this->getInternalByClassNameKey($class?->getName(), $keyWithName); @@ -136,6 +149,19 @@ private function getInternal(?ReflectionClass $class, ReflectionParameter $param return null; } + private function getQualifiedName(ReflectionParameter $param): ?string + { + $qualifierClass = $param->getAttributes(Qualify::class)[0] ?? null; + + if (!$qualifierClass) { + return null; + } + + $qualifier = $qualifierClass->newInstance(); + + return $qualifier->qualifier; + } + /** * @param ReflectionClass $class */ diff --git a/application/Espo/Core/Binding/BindingContainerBuilder.php b/application/Espo/Core/Binding/BindingContainerBuilder.php index b99b3c389af..a79c5a29ed9 100644 --- a/application/Espo/Core/Binding/BindingContainerBuilder.php +++ b/application/Espo/Core/Binding/BindingContainerBuilder.php @@ -31,6 +31,7 @@ use Closure; use Espo\Core\Binding\Key\NamedClassKey; +use Espo\Core\Binding\Key\QualifiedClassKey; class BindingContainerBuilder { @@ -47,10 +48,11 @@ public function __construct() * Bind an interface to an implementation. * * @template T of object - * @param class-string|NamedClassKey $key An interface or interface with a parameter name. + * @param class-string|NamedClassKey|QualifiedClassKey $key + * An interface, an interface with a parameter name or an interface with a qualifier. * @param class-string $implementationClassName An implementation class name. */ - public function bindImplementation(string|NamedClassKey $key, string $implementationClassName): self + public function bindImplementation(string|NamedClassKey|QualifiedClassKey $key, string $implementationClassName): self { $this->binder->bindImplementation($key, $implementationClassName); @@ -60,11 +62,12 @@ public function bindImplementation(string|NamedClassKey $key, string $implementa /** * Bind an interface to a specific service. * - * @param class-string|NamedClassKey $key An interface or interface with a parameter name. + * @param class-string|NamedClassKey|QualifiedClassKey $key + * An interface, an interface with a parameter name or an interface with a qualifier. * @param string $serviceName A service name. * @noinspection PhpUnused */ - public function bindService(string|NamedClassKey $key, string $serviceName): self + public function bindService(string|NamedClassKey|QualifiedClassKey $key, string $serviceName): self { $this->binder->bindService($key, $serviceName); @@ -75,12 +78,13 @@ public function bindService(string|NamedClassKey $key, string $serviceName): sel * Bind an interface to a callback. * * @template T of object - * @param class-string|NamedClassKey $key An interface or interface with a parameter name. + * @param class-string|NamedClassKey|QualifiedClassKey $key + * An interface, an interface with a parameter name or an interface with a qualifier. * @param Closure $callback A callback that will resolve a dependency. * @todo Change to Closure(...): T Once https://github.com/phpstan/phpstan/issues/8214 is implemented. * @noinspection PhpUnused */ - public function bindCallback(string|NamedClassKey $key, Closure $callback): self + public function bindCallback(string|NamedClassKey|QualifiedClassKey $key, Closure $callback): self { $this->binder->bindCallback($key, $callback); @@ -91,11 +95,11 @@ public function bindCallback(string|NamedClassKey $key, Closure $callback): self * Bind an interface to a specific instance. * * @template T of object - * @param class-string|NamedClassKey $key An interface or interface with a parameter name. + * @param class-string|NamedClassKey|QualifiedClassKey $key + * An interface, an interface with a parameter name or an interface with a qualifier. * @param T $instance An instance. - * @noinspection PhpDocSignatureInspection */ - public function bindInstance(string|NamedClassKey $key, object $instance): self + public function bindInstance(string|NamedClassKey|QualifiedClassKey $key, object $instance): self { $this->binder->bindInstance($key, $instance); @@ -106,11 +110,12 @@ public function bindInstance(string|NamedClassKey $key, object $instance): self * Bind an interface to a factory. * * @template T of object - * @param class-string|NamedClassKey $key An interface or interface with a parameter name. + * @param class-string|NamedClassKey|QualifiedClassKey $key + * An interface, an interface with a parameter name or an interface with a qualifier. * @param class-string> $factoryClassName A factory class name. * @noinspection PhpUnused */ - public function bindFactory(string|NamedClassKey $key, string $factoryClassName): self + public function bindFactory(string|NamedClassKey|QualifiedClassKey $key, string $factoryClassName): self { $this->binder->bindFactory($key, $factoryClassName); diff --git a/application/Espo/Core/Binding/ContextualBinder.php b/application/Espo/Core/Binding/ContextualBinder.php index de46cb341d7..e3cfd26fcfc 100644 --- a/application/Espo/Core/Binding/ContextualBinder.php +++ b/application/Espo/Core/Binding/ContextualBinder.php @@ -32,35 +32,34 @@ use Closure; use Espo\Core\Binding\Key\NamedClassKey; use Espo\Core\Binding\Key\NamedKey; +use Espo\Core\Binding\Key\QualifiedClassKey; use LogicException; class ContextualBinder { - private BindingData $data; - - /** @var class-string */ - private string $className; - /** * @param class-string $className */ - public function __construct(BindingData $data, string $className) - { - $this->data = $data; - $this->className = $className; - } + public function __construct( + private BindingData $data, + private string $className, + ) {} /** * Bind an interface to an implementation. * * @template T of object - * @param class-string|NamedClassKey $key An interface or interface with a parameter name. + * @param class-string|NamedClassKey|QualifiedClassKey $key + * An interface, an interface with a parameter name or an interface with a qualifier. * @param class-string $implementationClassName An implementation class name. */ - public function bindImplementation(string|NamedClassKey $key, string $implementationClassName): self - { + public function bindImplementation( + string|NamedClassKey|QualifiedClassKey $key, + string $implementationClassName, + ): self { + $key = self::keyToString($key); - $this->validateBindingKeyNoParameterName($key); + $this->validateBindingKeyNoName($key); $binding = Binding::createFromImplementationClassName($implementationClassName); @@ -73,13 +72,14 @@ public function bindImplementation(string|NamedClassKey $key, string $implementa * Bind an interface to a specific service. * * @template T of object - * @param class-string|NamedClassKey $key An interface or interface with a parameter name. + * @param class-string|NamedClassKey|QualifiedClassKey $key + * An interface, an interface with a parameter name or an interface with a qualifier. * @param string $serviceName A service name. */ - public function bindService(string|NamedClassKey $key, string $serviceName): self + public function bindService(string|NamedClassKey|QualifiedClassKey $key, string $serviceName): self { $key = self::keyToString($key); - $this->validateBindingKeyNoParameterName($key); + $this->validateBindingKeyNoName($key); $this->data->addContext($this->className, $key, Binding::createFromServiceName($serviceName)); @@ -89,7 +89,8 @@ public function bindService(string|NamedClassKey $key, string $serviceName): sel /** * Bind an interface or parameter name to a specific value. * - * @param string|NamedKey|NamedClassKey $key Parameter name (`$name`) or interface with a parameter name. + * @param string|NamedKey|NamedClassKey $key + * A parameter name (`$name`) or an interface with a parameter name. * @param mixed $value A value of any type. */ public function bindValue(string|NamedKey|NamedClassKey $key, $value): self @@ -106,13 +107,14 @@ public function bindValue(string|NamedKey|NamedClassKey $key, $value): self * Bind an interface to a specific instance. * * @template T of object - * @param class-string|NamedClassKey $key An interface or interface with a parameter name. + * @param class-string|NamedClassKey|QualifiedClassKey $key + * An interface, an interface with a parameter name or an interface with a qualifier. * @param T $instance An instance. */ - public function bindInstance(string|NamedClassKey $key, object $instance): self + public function bindInstance(string|NamedClassKey|QualifiedClassKey $key, object $instance): self { $key = self::keyToString($key); - $this->validateBindingKeyNoParameterName($key); + $this->validateBindingKeyNoName($key); $this->data->addContext($this->className, $key, Binding::createFromValue($instance)); @@ -122,11 +124,12 @@ public function bindInstance(string|NamedClassKey $key, object $instance): self /** * Bind an interface or parameter name to a callback. * - * @param class-string|NamedClassKey|NamedKey $key An interface, parameter name or both. + * @param class-string|NamedClassKey|NamedKey|QualifiedClassKey $key + * An interface, parameter name or both. * @param Closure $callback A callback that will resolve a dependency. * @todo Change to Closure(...): mixed Once https://github.com/phpstan/phpstan/issues/8214 is implemented. */ - public function bindCallback(string|NamedClassKey|NamedKey $key, Closure $callback): self + public function bindCallback(string|NamedClassKey|NamedKey|QualifiedClassKey $key, Closure $callback): self { $key = self::keyToString($key); $this->validateBinding($key); @@ -140,13 +143,14 @@ public function bindCallback(string|NamedClassKey|NamedKey $key, Closure $callba * Bind an interface to a factory. * * @template T of object - * @param class-string|NamedClassKey $key An interface or interface with a parameter name. + * @param class-string|NamedClassKey|QualifiedClassKey $key + * An interface, an interface with a parameter name or an interface with a qualifier. * @param class-string> $factoryClassName A factory class name. */ - public function bindFactory(string|NamedClassKey $key, string $factoryClassName): self + public function bindFactory(string|NamedClassKey|QualifiedClassKey $key, string $factoryClassName): self { $key = self::keyToString($key); - $this->validateBindingKeyNoParameterName($key); + $this->validateBindingKeyNoName($key); $this->data->addContext($this->className, $key, Binding::createFromFactoryClassName($factoryClassName)); @@ -160,13 +164,17 @@ private function validateBinding(string $key): void } } - private function validateBindingKeyNoParameterName(string $key): void + private function validateBindingKeyNoName(string $key): void { $this->validateBinding($key); if ($key[0] === '$') { throw new LogicException("Can't bind a parameter name w/o an interface."); } + + if ($key[0] === '#') { + throw new LogicException("Can't bind a qualification name w/o an interface."); + } } private function validateBindingKeyParameterName(string $key): void @@ -179,9 +187,9 @@ private function validateBindingKeyParameterName(string $key): void } /** - * @param string|NamedKey|NamedClassKey $key + * @param string|NamedKey|NamedClassKey|QualifiedClassKey $key */ - private static function keyToString(string|NamedKey|NamedClassKey $key): string + private static function keyToString(string|NamedKey|NamedClassKey|QualifiedClassKey $key): string { return is_string($key) ? $key : $key->toString(); } diff --git a/application/Espo/Core/Binding/Key/QualifiedClassKey.php b/application/Espo/Core/Binding/Key/QualifiedClassKey.php new file mode 100644 index 00000000000..9790b7f4448 --- /dev/null +++ b/application/Espo/Core/Binding/Key/QualifiedClassKey.php @@ -0,0 +1,69 @@ +. + * + * 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\Binding\Key; + +/** + * A key for a class-type-hinted constructor parameter with a qualifier. + * Use it to bind to qualified constructor parameters. For example, one interface + * can be used with a qualifier and without it. In the latter case, + * the default binding it applied. While in the formed case, a special binding is applied. + * For example, a caching pool interface may be bound to two different services: + * core cache and application cache. + * + * @since 10.1.0 + * + * @template-covariant T of object + */ +class QualifiedClassKey +{ + /** + * @param class-string $className + */ + private function __construct(private string $className, private string $qualifier) + {} + + /** + * Create. + * + * @template TC of object + * @param class-string $className An interface. + * @param string $qualifier A qualification name. + * @return self + */ + public static function create(string $className, string $qualifier): self + { + return new self($className, $qualifier); + } + + public function toString(): string + { + return $this->className . ' #' . $this->qualifier; + } +} diff --git a/tests/integration/Espo/Core/Binding/BindingTest.php b/tests/integration/Espo/Core/Binding/BindingTest.php index bc63443e4e0..5bb2d80404f 100644 --- a/tests/integration/Espo/Core/Binding/BindingTest.php +++ b/tests/integration/Espo/Core/Binding/BindingTest.php @@ -30,14 +30,17 @@ namespace tests\integration\Espo\Core\Binding; use Espo\Core\Application\ApplicationParams; +use Espo\Core\Binding\Binder; use Espo\Core\Binding\Binding; use Espo\Core\Binding\BindingData; use Espo\Core\Binding\BindingLoader; +use Espo\Core\Binding\Key\QualifiedClassKey; use Espo\Core\Container\ContainerBuilder; - +use Espo\Core\InjectableFactory; use tests\integration\Core\BaseTestCase; use tests\integration\testClasses\Binding\SomeClass; use tests\integration\testClasses\Binding\SomeClassRequiringService; +use tests\integration\testClasses\Binding\SomeClassRequiringServiceAlt; use tests\integration\testClasses\Binding\SomeClassRequiringValue; use tests\integration\testClasses\Binding\SomeFactory; use tests\integration\testClasses\Binding\SomeImplementation; @@ -72,6 +75,8 @@ public function load(): BindingData $injectableFactory = $container->get('injectableFactory'); + $this->assertInstanceOf(InjectableFactory::class, $injectableFactory); + $obj = $injectableFactory->create(SomeClass::class); $this->assertNotNull($obj); @@ -108,6 +113,8 @@ public function load(): BindingData $injectableFactory = $container->get('injectableFactory'); + $this->assertInstanceOf(InjectableFactory::class, $injectableFactory); + $obj = $injectableFactory->create(SomeClass::class); $this->assertNotNull($obj); @@ -122,7 +129,7 @@ public function testCallback() { $bindingLoader = new class() implements BindingLoader { - public function load() : BindingData + public function load(): BindingData { $data = new BindingData(); @@ -146,6 +153,8 @@ function (SomeImplementation $some) { $injectableFactory = $container->get('injectableFactory'); + $this->assertInstanceOf(InjectableFactory::class, $injectableFactory); + $obj = $injectableFactory->create(SomeClass::class); $this->assertNotNull($obj); @@ -160,7 +169,7 @@ public function testService() { $bindingLoader = new class() implements BindingLoader { - public function load() : BindingData + public function load(): BindingData { $data = new BindingData(); @@ -185,6 +194,8 @@ public function load() : BindingData $injectableFactory = $container->get('injectableFactory'); + $this->assertInstanceOf(InjectableFactory::class, $injectableFactory); + $obj = $injectableFactory->create(SomeClassRequiringService::class); $this->assertNotNull($obj); @@ -199,7 +210,7 @@ public function testValue() { $bindingLoader = new class() implements BindingLoader { - public function load() : BindingData + public function load(): BindingData { $data = new BindingData(); @@ -220,6 +231,8 @@ public function load() : BindingData $injectableFactory = $container->get('injectableFactory'); + $this->assertInstanceOf(InjectableFactory::class, $injectableFactory); + $obj = $injectableFactory->create(SomeClassRequiringValue::class); $this->assertNotNull($obj); @@ -229,4 +242,46 @@ public function load() : BindingData $obj->getValue() ); } + + public function testQualifier(): void + { + $bindingLoader = new class() implements BindingLoader + { + public function load(): BindingData + { + $data = new BindingData(); + $binder = new Binder($data); + + $binder->bindService(SomeService::class, 'testService'); + $binder->bindService(QualifiedClassKey::create(SomeService::class, 'alt'), 'testServiceAlt'); + + return $data; + } + }; + + $testService = new SomeService(); + $testServiceAlt = new SomeService('alt'); + + $container = (new ContainerBuilder()) + ->withBindingLoader($bindingLoader) + ->withParams(new ApplicationParams(noErrorHandler: true)) + ->withServices([ + 'testService' => $testService, + 'testServiceAlt' => $testServiceAlt, + ]) + ->build(); + + $injectableFactory = $container->get('injectableFactory'); + + $this->assertInstanceOf(InjectableFactory::class, $injectableFactory); + + $obj = $injectableFactory->create(SomeClassRequiringService::class); + $objAlt = $injectableFactory->create(SomeClassRequiringServiceAlt::class); + + $this->assertInstanceOf(SomeService::class, $obj->getService()); + $this->assertEquals('default', $obj->getService()->name); + + $this->assertInstanceOf(SomeService::class, $objAlt->getService()); + $this->assertEquals('alt', $objAlt->getService()->name); + } } diff --git a/tests/integration/testClasses/Binding/SomeClass.php b/tests/integration/testClasses/Binding/SomeClass.php index 7b46554058e..16644db0f2e 100644 --- a/tests/integration/testClasses/Binding/SomeClass.php +++ b/tests/integration/testClasses/Binding/SomeClass.php @@ -31,7 +31,7 @@ class SomeClass { - private $someImplementation; + private SomeInterface $someImplementation; public function __construct(SomeInterface $someImplementation) { diff --git a/tests/integration/testClasses/Binding/SomeClassRequiringService.php b/tests/integration/testClasses/Binding/SomeClassRequiringService.php index 48eb53a026a..e6ad6c0c7b5 100644 --- a/tests/integration/testClasses/Binding/SomeClassRequiringService.php +++ b/tests/integration/testClasses/Binding/SomeClassRequiringService.php @@ -31,14 +31,11 @@ class SomeClassRequiringService { - private $service; + public function __construct( + private SomeService $service + ) {} - public function __construct(SomeService $service) - { - $this->service = $service; - } - - public function getService() : SomeService + public function getService(): SomeService { return $this->service; } diff --git a/tests/integration/testClasses/Binding/SomeClassRequiringServiceAlt.php b/tests/integration/testClasses/Binding/SomeClassRequiringServiceAlt.php new file mode 100644 index 00000000000..75f4ca80d86 --- /dev/null +++ b/tests/integration/testClasses/Binding/SomeClassRequiringServiceAlt.php @@ -0,0 +1,46 @@ +. + * + * 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 tests\integration\testClasses\Binding; + +use Espo\Core\Binding\Attributes\Qualify; + +class SomeClassRequiringServiceAlt +{ + public function __construct( + #[Qualify('alt')] + private SomeService $service, + ) {} + + public function getService(): SomeService + { + return $this->service; + } +} + diff --git a/tests/integration/testClasses/Binding/SomeService.php b/tests/integration/testClasses/Binding/SomeService.php index 9d82e563bdf..c7cb7d7c1d7 100644 --- a/tests/integration/testClasses/Binding/SomeService.php +++ b/tests/integration/testClasses/Binding/SomeService.php @@ -29,7 +29,9 @@ namespace tests\integration\testClasses\Binding; -class SomeService +readonly class SomeService { - + public function __construct( + public ?string $name = 'default', + ) {} } diff --git a/tests/unit/Espo/Core/Binding/BindingContainerTest.php b/tests/unit/Espo/Core/Binding/BindingContainerTest.php index 5c5b0dc1e37..82acc106eba 100644 --- a/tests/unit/Espo/Core/Binding/BindingContainerTest.php +++ b/tests/unit/Espo/Core/Binding/BindingContainerTest.php @@ -39,6 +39,7 @@ use Espo\Core\Binding\Key\NamedClassKey; use Espo\Core\Binding\Key\NamedKey; +use Espo\Core\Binding\Key\QualifiedClassKey; use PHPUnit\Framework\TestCase; use ReflectionClass; use ReflectionParameter; @@ -46,6 +47,8 @@ use tests\unit\testClasses\Core\Binding\Class0; use tests\unit\testClasses\Core\Binding\Class1; +use tests\unit\testClasses\Core\Binding\SomeClass1Alt; +use tests\unit\testClasses\Core\Binding\SomeClass2Alt; use tests\unit\testClasses\Core\Binding\SomeInterface1; use tests\unit\testClasses\Core\Binding\SomeInterface2; use tests\unit\testClasses\Core\Binding\SomeClass1; @@ -53,9 +56,8 @@ class BindingContainerTest extends TestCase { - /** @var Binder */ - private $binder; - private $loader; + private ?Binder $binder = null; + private ?BindingLoader $loader = null; protected function setUp(): void { @@ -71,7 +73,7 @@ protected function setUp(): void ->willReturn($data); } - protected function createClassMock(string $className) : ReflectionClass + private function createClassMock(string $className): ReflectionClass { $class = $this->createMock(ReflectionClass::class); @@ -83,7 +85,7 @@ protected function createClassMock(string $className) : ReflectionClass return $class; } - protected function createParamMock(string $name, ?string $className = null) : ReflectionParameter + private function createParamMock(string $name, ?string $className = null, ?string $qualifier = null): ReflectionParameter { $param = $this->createMock(ReflectionParameter::class); @@ -125,6 +127,17 @@ protected function createParamMock(string $name, ?string $className = null) : Re ->method('getClass') ->willReturn($class); + if ($qualifier) { + $attribute = $this->createMock(ReflectionClass::class); + + $param + ->method('getAttributes') + ->willReturn([$attribute]); + + $attribute->method('newInstance') + ->willReturn((object) ['qualifier' => $qualifier]); + } + return $param; } @@ -133,7 +146,7 @@ protected function createContainer(): BindingContainer return new BindingContainer($this->loader->load()); } - public function testHasTrue() + public function testHasTrue(): void { $this->binder->bindService('Espo\\Test', 'test'); @@ -146,7 +159,7 @@ public function testHasTrue() ); } - public function testHasNoContextTrue() + public function testHasNoContextTrue(): void { $this->binder->bindService('Espo\\Test', 'test'); @@ -157,7 +170,7 @@ public function testHasNoContextTrue() ); } - public function testHasFalse() + public function testHasFalse(): void { $this->binder->bindService('Espo\\Test', 'test'); @@ -170,7 +183,7 @@ public function testHasFalse() ); } - public function testHasNoContextFalse() + public function testHasNoContextFalse(): void { $this->binder->bindService('Espo\\Test', 'test'); @@ -181,7 +194,7 @@ public function testHasNoContextFalse() ); } - public function testHasContextTrue0() + public function testHasContextTrue0(): void { $this->binder ->inContext('Espo\\Context', function (ContextualBinder $binder): void { @@ -197,7 +210,7 @@ public function testHasContextTrue0() ); } - public function testHasContextTrue1() + public function testHasContextTrue1(): void { $this->binder ->for('Espo\\Context') @@ -212,7 +225,7 @@ public function testHasContextTrue1() ); } - public function testHasContextTrue2() + public function testHasContextTrue2(): void { $this->binder ->for('Espo\\Context') @@ -227,7 +240,7 @@ public function testHasContextTrue2() ); } - public function testHasContextTrue3() + public function testHasContextTrue3(): void { $this->binder ->for('Espo\\Context') @@ -242,7 +255,7 @@ public function testHasContextTrue3() ); } - public function testHasContextTrue4() + public function testHasContextTrue4(): void { $this->binder ->for('Espo\\Context') @@ -256,7 +269,7 @@ public function testHasContextTrue4() ); } - public function testHasContextFalse1() + public function testHasContextFalse1(): void { $this->binder ->for('Espo\\Context') @@ -270,7 +283,7 @@ public function testHasContextFalse1() ); } - public function testHasContextFalse2() + public function testHasContextFalse2(): void { $this->binder ->for('Espo\\Context') @@ -285,7 +298,7 @@ public function testHasContextFalse2() ); } - public function testHasContextFalse3() + public function testHasContextFalse3(): void { $this->binder ->for('Espo\\Context') @@ -326,7 +339,7 @@ public function testGetClassNameFactory(): void $this->assertEquals('Espo\\TestFactory', $binding->getValue()); } - public function testGetService() + public function testGetService(): void { $this->binder->bindService('Espo\\Test', 'test'); @@ -341,7 +354,7 @@ public function testGetService() $this->assertEquals('test', $binding->getValue()); } - public function testGetCallback() + public function testGetCallback(): void { $this->binder->bindCallback( 'Espo\\Test', @@ -361,7 +374,7 @@ function () { $this->assertIsCallable($binding->getValue()); } - public function testBindInstance() + public function testBindInstance(): void { $className = 'Espo\\Core\\Application'; @@ -378,7 +391,7 @@ public function testBindInstance() $this->assertSame($instance, $binding->getValue()); } - public function testContextBindInstance() + public function testContextBindInstance(): void { $className = 'Espo\\Core\\Application'; @@ -399,7 +412,7 @@ public function testContextBindInstance() $this->assertSame($instance, $binding->getValue()); } - public function testContextGetCallback() + public function testContextGetCallback(): void { $this->binder ->for('Espo\\Context') @@ -421,7 +434,7 @@ function () { $this->assertIsCallable($binding->getValue()); } - public function testRebindGlobal() + public function testRebindGlobal(): void { $this->binder->bindService('Espo\\Test', 'test'); @@ -438,7 +451,7 @@ public function testRebindGlobal() $this->assertEquals('testHello', $binding->getValue()); } - public function testBindInterfaceWithParamNameGlobal() + public function testBindInterfaceWithParamNameGlobal(): void { $this->binder->bindService('Espo\\Test $name', 'testName'); @@ -495,7 +508,7 @@ public function testContextGetClassNameFactory(): void $this->assertEquals('Espo\\TestFactory', $binding->getValue()); } - public function testNoContextClassName() + public function testNoContextClassName(): void { $this->binder ->for('Espo\\Context') @@ -510,7 +523,7 @@ public function testNoContextClassName() ); } - public function testBindContextInterfaceWithParamNameGlobal() + public function testBindContextInterfaceWithParamNameGlobal(): void { $this->binder ->for('Espo\\Context') @@ -537,7 +550,7 @@ public function testBindContextInterfaceWithParamNameGlobal() $this->assertEquals('test', $binding->getValue()); } - public function testGetContextParamValue() + public function testGetContextParamValue(): void { $this->binder ->for('Espo\\Context') @@ -554,7 +567,7 @@ public function testGetContextParamValue() $this->assertEquals('Test Value', $binding->getValue()); } - public function testGetContextInterfaceValue1() + public function testGetContextInterfaceValue1(): void { $instance = (object) []; @@ -571,7 +584,7 @@ public function testGetContextInterfaceValue1() $this->assertEquals($instance, $binding->getValue()); } - public function testGetContextInterfaceValue2() + public function testGetContextInterfaceValue2(): void { $instance = (object) []; @@ -588,7 +601,7 @@ public function testGetContextInterfaceValue2() $this->assertEquals($instance, $binding->getValue()); } - public function testGetContextService() + public function testGetContextService(): void { $this->binder ->for('Espo\\Context') @@ -605,7 +618,7 @@ public function testGetContextService() $this->assertEquals('test', $binding->getValue()); } - public function testRebindContextService() + public function testRebindContextService(): void { $this->binder ->for('Espo\\Context') @@ -667,7 +680,7 @@ public function testTypedParamWithScalarBound2(): void { $container = BindingContainerBuilder::create() ->inContext(Class0::class, function (ContextualBinder $binder): void { - $binder->bindValue('$dep', new Class1()); + $binder->bindValue(NamedKey::create('dep'), new Class1()); }) ->build(); @@ -682,4 +695,54 @@ public function testTypedParamWithScalarBound2(): void $this->assertEquals(Binding::VALUE, $binding->getType()); $this->assertInstanceOf(Class1::class, $binding->getValue()); } + + public function testBindingQualified(): void + { + $container = BindingContainerBuilder::create() + ->bindImplementation(SomeInterface1::class, SomeClass1::class) + ->bindImplementation(QualifiedClassKey::create(SomeInterface1::class, 'q1'), SomeClass1Alt::class) + ->bindImplementation(QualifiedClassKey::create(SomeInterface2::class, 'q3'), SomeClass2Alt::class) + ->inContext(SomeClass1::class, function (ContextualBinder $binder): void { + $binder->bindImplementation(SomeInterface2::class, SomeClass2::class); + $binder->bindImplementation(QualifiedClassKey::create(SomeInterface2::class, 'q2'), SomeClass2Alt::class); + }) + ->build(); + + $param1 = $this->createParamMock('test', SomeInterface1::class, 'q1'); + $this->assertTrue($container->hasByParam(null, $param1)); + $binding = $container->getByParam(null, $param1); + $this->assertEquals(SomeClass1Alt::class, $binding->getValue()); + + // + + $param2 = $this->createParamMock('test', SomeInterface1::class); + $this->assertTrue($container->hasByParam(null, $param2)); + $binding = $container->getByParam(null, $param2); + $this->assertEquals(SomeClass1::class, $binding->getValue()); + + // + + $contextClass = $this->createClassMock(SomeClass1::class); + + // + + $param3 = $this->createParamMock('test', SomeInterface2::class, 'q2'); + $this->assertTrue($container->hasByParam($contextClass, $param3)); + $binding = $container->getByParam($contextClass, $param3); + $this->assertEquals(SomeClass2Alt::class, $binding->getValue()); + + // + + $param4 = $this->createParamMock('test', SomeInterface2::class); + $this->assertTrue($container->hasByParam($contextClass, $param4)); + $binding = $container->getByParam($contextClass, $param4); + $this->assertEquals(SomeClass2::class, $binding->getValue()); + + // + + $param3 = $this->createParamMock('test', SomeInterface2::class, 'q3'); + $this->assertTrue($container->hasByParam($contextClass, $param3)); + $binding = $container->getByParam($contextClass, $param3); + $this->assertEquals(SomeClass2Alt::class, $binding->getValue()); + } } diff --git a/tests/unit/testClasses/Core/Binding/SomeClass0.php b/tests/unit/testClasses/Core/Binding/SomeClass0.php index 30248cac9d7..98f333e7a9a 100644 --- a/tests/unit/testClasses/Core/Binding/SomeClass0.php +++ b/tests/unit/testClasses/Core/Binding/SomeClass0.php @@ -31,10 +31,6 @@ class SomeClass0 { - private $dep; - - public function __construct(SomeInterface1 $dep) - { - $this->dep = $dep; - } + public function __construct(public SomeInterface1 $dep) + {} } diff --git a/tests/unit/testClasses/Core/Binding/SomeClass1.php b/tests/unit/testClasses/Core/Binding/SomeClass1.php index e4dfeef428f..4e5759ba9ac 100644 --- a/tests/unit/testClasses/Core/Binding/SomeClass1.php +++ b/tests/unit/testClasses/Core/Binding/SomeClass1.php @@ -31,10 +31,6 @@ class SomeClass1 implements SomeInterface1 { - private $dep; - - public function __construct(SomeInterface2 $dep) - { - $this->dep = $dep; - } + public function __construct(public SomeInterface2 $dep) + {} } diff --git a/tests/unit/testClasses/Core/Binding/SomeClass1Alt.php b/tests/unit/testClasses/Core/Binding/SomeClass1Alt.php new file mode 100644 index 00000000000..807a7bfbdf2 --- /dev/null +++ b/tests/unit/testClasses/Core/Binding/SomeClass1Alt.php @@ -0,0 +1,36 @@ +. + * + * 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 tests\unit\testClasses\Core\Binding; + +class SomeClass1Alt implements SomeInterface1 +{ + public function __construct(public SomeInterface2 $dep) + {} +} diff --git a/tests/unit/testClasses/Core/Binding/SomeClass2.php b/tests/unit/testClasses/Core/Binding/SomeClass2.php index 171dc352f31..fb6d33dbf50 100644 --- a/tests/unit/testClasses/Core/Binding/SomeClass2.php +++ b/tests/unit/testClasses/Core/Binding/SomeClass2.php @@ -30,6 +30,4 @@ namespace tests\unit\testClasses\Core\Binding; class SomeClass2 implements SomeInterface2 -{ - -} +{} diff --git a/tests/unit/testClasses/Core/Binding/SomeClass2Alt.php b/tests/unit/testClasses/Core/Binding/SomeClass2Alt.php new file mode 100644 index 00000000000..2f2e051a4d1 --- /dev/null +++ b/tests/unit/testClasses/Core/Binding/SomeClass2Alt.php @@ -0,0 +1,33 @@ +. + * + * 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 tests\unit\testClasses\Core\Binding; + +class SomeClass2Alt implements SomeInterface2 +{} diff --git a/tests/unit/testClasses/Core/Binding/SomeInterface2.php b/tests/unit/testClasses/Core/Binding/SomeInterface2.php index 9bf19f67eb8..1eec5045f7f 100644 --- a/tests/unit/testClasses/Core/Binding/SomeInterface2.php +++ b/tests/unit/testClasses/Core/Binding/SomeInterface2.php @@ -30,6 +30,4 @@ namespace tests\unit\testClasses\Core\Binding; interface SomeInterface2 -{ - -} +{} From b7e6e033a8938f269b68722e690b577ea7e21576 Mon Sep 17 00:00:00 2001 From: Yurii Date: Mon, 20 Jul 2026 13:09:12 +0300 Subject: [PATCH 46/87] Use qualifier --- application/Espo/Binding.php | 16 ++++++++++++++ .../FieldProcessing/CurrencyRecord/Label.php | 4 +++- .../Core/Authentication/Ldap/LdapLogin.php | 2 ++ application/Espo/Core/Utils/Language.php | 21 ++++++++++++++++++ .../Core/Utils/ScheduledJob/Populator.php | 2 ++ .../Espo/Hooks/Pipeline/CreateStages.php | 2 ++ .../Crm/Tools/Campaign/MailMergeService.php | 22 ++++++------------- .../Crm/Tools/MassEmail/SendingProcessor.php | 2 ++ .../Tools/EntityManager/EntityManager.php | 3 +++ .../Hook/Hooks/CategoriesUpdateHook.php | 2 ++ .../Hook/Hooks/EventCreateHook.php | 2 ++ .../Hook/Hooks/EventDeleteHook.php | 2 ++ .../Espo/Tools/FieldManager/FieldManager.php | 2 ++ .../Espo/Tools/LeadCapture/CaptureService.php | 2 ++ .../Tools/LeadCapture/ConfirmationSender.php | 2 ++ .../Espo/Tools/LeadCapture/FormService.php | 2 ++ .../Espo/Tools/LinkManager/LinkManager.php | 2 ++ application/Espo/Tools/Pdf/MassService.php | 2 ++ 18 files changed, 76 insertions(+), 16 deletions(-) diff --git a/application/Espo/Binding.php b/application/Espo/Binding.php index e307a18b402..69417feb1a3 100644 --- a/application/Espo/Binding.php +++ b/application/Espo/Binding.php @@ -32,6 +32,7 @@ use Espo\Core\Binding\Binder; use Espo\Core\Binding\BindingProcessor; use Espo\Core\Binding\Key\NamedClassKey; +use Espo\Core\Binding\Key\QualifiedClassKey; /** * Default binding for the dependency injection framework. Custom binding should be set up in @@ -178,16 +179,31 @@ private function bindServices(Binder $binder): void 'baseLanguage' ); + $binder->bindService( + QualifiedClassKey::create('Espo\\Core\\Utils\\Language', 'base'), + 'baseLanguage' + ); + $binder->bindService( NamedClassKey::create('Espo\\Core\\Utils\\Language', 'defaultLanguage'), 'defaultLanguage' ); + $binder->bindService( + QualifiedClassKey::create('Espo\\Core\\Utils\\Language', 'default'), + 'defaultLanguage' + ); + $binder->bindService( 'Espo\\Core\\Utils\\Language', 'language' ); + $binder->bindService( + QualifiedClassKey::create('Espo\\Core\\Utils\\Language', 'user'), + 'language' + ); + $binder->bindService( 'Espo\\Core\\Formula\\Manager', 'formulaManager' diff --git a/application/Espo/Classes/FieldProcessing/CurrencyRecord/Label.php b/application/Espo/Classes/FieldProcessing/CurrencyRecord/Label.php index cec42b951f1..68e23dcd311 100644 --- a/application/Espo/Classes/FieldProcessing/CurrencyRecord/Label.php +++ b/application/Espo/Classes/FieldProcessing/CurrencyRecord/Label.php @@ -29,6 +29,7 @@ namespace Espo\Classes\FieldProcessing\CurrencyRecord; +use Espo\Core\Binding\Attributes\Qualify; use Espo\Core\FieldProcessing\Loader; use Espo\Core\FieldProcessing\Loader\Params; use Espo\Core\Utils\Language; @@ -42,7 +43,8 @@ class Label implements Loader { public function __construct( - private Language $defaultLanguage + #[Qualify(Language::QUALIFIER_DEFAULT)] + private Language $defaultLanguage, ) {} public function process(Entity $entity, Params $params): void diff --git a/application/Espo/Core/Authentication/Ldap/LdapLogin.php b/application/Espo/Core/Authentication/Ldap/LdapLogin.php index bba79390717..437263c543b 100644 --- a/application/Espo/Core/Authentication/Ldap/LdapLogin.php +++ b/application/Espo/Core/Authentication/Ldap/LdapLogin.php @@ -30,6 +30,7 @@ namespace Espo\Core\Authentication\Ldap; use Espo\Core\Api\Util; +use Espo\Core\Binding\Attributes\Qualify; use Espo\Core\FieldProcessing\Relation\LinkMultipleSaver; use Espo\Core\FieldProcessing\EmailAddress\Saver as EmailAddressSaver; use Espo\Core\FieldProcessing\PhoneNumber\Saver as PhoneNumberSaver; @@ -73,6 +74,7 @@ public function __construct( private Config $config, private EntityManager $entityManager, private PasswordHash $passwordHash, + #[Qualify(Language::QUALIFIER_DEFAULT)] Language $defaultLanguage, private Log $log, private Espo $baseLogin, diff --git a/application/Espo/Core/Utils/Language.php b/application/Espo/Core/Utils/Language.php index 17752e8ff57..5c03b62a46f 100644 --- a/application/Espo/Core/Utils/Language.php +++ b/application/Espo/Core/Utils/Language.php @@ -39,6 +39,27 @@ class Language { + /** + * DI qualifier. The language selected as the system default. + * + * @since 10.1.0 + */ + public const string QUALIFIER_DEFAULT = 'default'; + + /** + * DI qualifier. The base language, en_US. + * + * @since 10.1.0 + */ + public const string QUALIFIER_BASE = 'base'; + + /** + * DI qualifier. The language selected in the current user's preferences. + * + * @since 10.1.0 + */ + public const string QUALIFIER_USER = 'user'; + /** @var array> */ private $data = []; /** @var array> */ diff --git a/application/Espo/Core/Utils/ScheduledJob/Populator.php b/application/Espo/Core/Utils/ScheduledJob/Populator.php index 5faded12d01..e9705f3dc40 100644 --- a/application/Espo/Core/Utils/ScheduledJob/Populator.php +++ b/application/Espo/Core/Utils/ScheduledJob/Populator.php @@ -29,6 +29,7 @@ namespace Espo\Core\Utils\ScheduledJob; +use Espo\Core\Binding\Attributes\Qualify; use Espo\Core\Utils\Language; use Espo\Core\Utils\Metadata; use Espo\Entities\ScheduledJob; @@ -43,6 +44,7 @@ class Populator public function __construct( private EntityManager $entityManager, private Metadata $metadata, + #[Qualify(Language::QUALIFIER_DEFAULT)] private Language $defaultLanguage, ) {} diff --git a/application/Espo/Hooks/Pipeline/CreateStages.php b/application/Espo/Hooks/Pipeline/CreateStages.php index 35f9755f075..57be4cabe02 100644 --- a/application/Espo/Hooks/Pipeline/CreateStages.php +++ b/application/Espo/Hooks/Pipeline/CreateStages.php @@ -29,6 +29,7 @@ namespace Espo\Hooks\Pipeline; +use Espo\Core\Binding\Attributes\Qualify; use Espo\Core\Hook\Hook\AfterSave; use Espo\Core\ORM\Repository\Option\SaveOption; use Espo\Core\Utils\Language; @@ -47,6 +48,7 @@ class CreateStages implements AfterSave { public function __construct( private EntityManager $entityManager, + #[Qualify(Language::QUALIFIER_DEFAULT)] private Language $defaultLanguage, private Defs $defs, private EnumOptionsProvider $enumOptionsProvider, diff --git a/application/Espo/Modules/Crm/Tools/Campaign/MailMergeService.php b/application/Espo/Modules/Crm/Tools/Campaign/MailMergeService.php index 795a9ddc489..4a408752921 100644 --- a/application/Espo/Modules/Crm/Tools/Campaign/MailMergeService.php +++ b/application/Espo/Modules/Crm/Tools/Campaign/MailMergeService.php @@ -30,6 +30,7 @@ namespace Espo\Modules\Crm\Tools\Campaign; use Espo\Core\Acl; +use Espo\Core\Binding\Attributes\Qualify; use Espo\Core\Exceptions\BadRequest; use Espo\Core\Exceptions\Error; use Espo\Core\Exceptions\Forbidden; @@ -61,22 +62,13 @@ class MailMergeService 'users', ]; - private EntityManager $entityManager; - private Acl $acl; - private Language $defaultLanguage; - private MailMergeGenerator $generator; - public function __construct( - EntityManager $entityManager, - Acl $acl, - Language $defaultLanguage, - MailMergeGenerator $generator - ) { - $this->entityManager = $entityManager; - $this->acl = $acl; - $this->defaultLanguage = $defaultLanguage; - $this->generator = $generator; - } + private EntityManager $entityManager, + private Acl $acl, + #[Qualify(Language::QUALIFIER_DEFAULT)] + private Language $defaultLanguage, + private MailMergeGenerator $generator, + ) {} /** * @return string An attachment ID. diff --git a/application/Espo/Modules/Crm/Tools/MassEmail/SendingProcessor.php b/application/Espo/Modules/Crm/Tools/MassEmail/SendingProcessor.php index 7ed57b7259d..b050e3f44b0 100644 --- a/application/Espo/Modules/Crm/Tools/MassEmail/SendingProcessor.php +++ b/application/Espo/Modules/Crm/Tools/MassEmail/SendingProcessor.php @@ -29,6 +29,7 @@ namespace Espo\Modules\Crm\Tools\MassEmail; +use Espo\Core\Binding\Attributes\Qualify; use Espo\Modules\Crm\Tools\MassEmail\MessagePreparator\Headers; use Espo\Core\Field\DateTime; use Espo\Core\Mail\ConfigDataProvider; @@ -73,6 +74,7 @@ class SendingProcessor public function __construct( private Config $config, private EntityManager $entityManager, + #[Qualify(Language::QUALIFIER_DEFAULT)] private Language $defaultLanguage, private EmailSender $emailSender, private Log $log, diff --git a/application/Espo/Tools/EntityManager/EntityManager.php b/application/Espo/Tools/EntityManager/EntityManager.php index 1dab8900d01..3f49139adba 100644 --- a/application/Espo/Tools/EntityManager/EntityManager.php +++ b/application/Espo/Tools/EntityManager/EntityManager.php @@ -29,6 +29,7 @@ namespace Espo\Tools\EntityManager; +use Espo\Core\Binding\Attributes\Qualify; use Espo\Core\Exceptions\Error; use Espo\Core\Exceptions\BadRequest; use Espo\Core\Exceptions\Forbidden; @@ -67,7 +68,9 @@ class EntityManager public function __construct( private Metadata $metadata, + #[Qualify(Language::QUALIFIER_USER)] private Language $language, + #[Qualify(Language::QUALIFIER_BASE)] private Language $baseLanguage, private FileManager $fileManager, private Config $config, diff --git a/application/Espo/Tools/EntityManager/Hook/Hooks/CategoriesUpdateHook.php b/application/Espo/Tools/EntityManager/Hook/Hooks/CategoriesUpdateHook.php index 46e6420257a..09947900996 100644 --- a/application/Espo/Tools/EntityManager/Hook/Hooks/CategoriesUpdateHook.php +++ b/application/Espo/Tools/EntityManager/Hook/Hooks/CategoriesUpdateHook.php @@ -29,6 +29,7 @@ namespace Espo\Tools\EntityManager\Hook\Hooks; +use Espo\Core\Binding\Attributes\Qualify; use Espo\Core\DataManager; use Espo\Core\Exceptions\BadRequest; use Espo\Core\Exceptions\Conflict; @@ -63,6 +64,7 @@ class CategoriesUpdateHook implements UpdateHook public function __construct( private InjectableFactory $injectableFactory, + #[Qualify(Language::QUALIFIER_DEFAULT)] private Language $defaultLanguage, private Metadata $metadata, private DataManager $dataManager, diff --git a/application/Espo/Tools/EntityManager/Hook/Hooks/EventCreateHook.php b/application/Espo/Tools/EntityManager/Hook/Hooks/EventCreateHook.php index d28d1c7705e..374462f6532 100644 --- a/application/Espo/Tools/EntityManager/Hook/Hooks/EventCreateHook.php +++ b/application/Espo/Tools/EntityManager/Hook/Hooks/EventCreateHook.php @@ -29,6 +29,7 @@ namespace Espo\Tools\EntityManager\Hook\Hooks; +use Espo\Core\Binding\Attributes\Qualify; use Espo\Core\Templates\Entities\Event; use Espo\Core\Utils\Language; use Espo\Tools\EntityManager\Hook\CreateHook; @@ -37,6 +38,7 @@ class EventCreateHook implements CreateHook { public function __construct( + #[Qualify(Language::QUALIFIER_BASE)] private Language $baseLanguage, private Language $language ) {} diff --git a/application/Espo/Tools/EntityManager/Hook/Hooks/EventDeleteHook.php b/application/Espo/Tools/EntityManager/Hook/Hooks/EventDeleteHook.php index 5d286c76c39..bb5149f75a0 100644 --- a/application/Espo/Tools/EntityManager/Hook/Hooks/EventDeleteHook.php +++ b/application/Espo/Tools/EntityManager/Hook/Hooks/EventDeleteHook.php @@ -29,6 +29,7 @@ namespace Espo\Tools\EntityManager\Hook\Hooks; +use Espo\Core\Binding\Attributes\Qualify; use Espo\Core\Templates\Entities\Event; use Espo\Core\Utils\Language; use Espo\Tools\EntityManager\Hook\DeleteHook; @@ -37,6 +38,7 @@ class EventDeleteHook implements DeleteHook { public function __construct( + #[Qualify(Language::QUALIFIER_BASE)] private Language $baseLanguage, private Language $language ) {} diff --git a/application/Espo/Tools/FieldManager/FieldManager.php b/application/Espo/Tools/FieldManager/FieldManager.php index ec52540d287..8939181cf78 100644 --- a/application/Espo/Tools/FieldManager/FieldManager.php +++ b/application/Espo/Tools/FieldManager/FieldManager.php @@ -29,6 +29,7 @@ namespace Espo\Tools\FieldManager; +use Espo\Core\Binding\Attributes\Qualify; use Espo\Core\ORM\Type\FieldType; use Espo\Core\Utils\Metadata; use Espo\Core\Utils\Language; @@ -65,6 +66,7 @@ public function __construct( private InjectableFactory $injectableFactory, private Metadata $metadata, private Language $language, + #[Qualify(Language::QUALIFIER_BASE)] private Language $baseLanguage, private MetadataHelper $metadataHelper, private NameUtil $nameUtil diff --git a/application/Espo/Tools/LeadCapture/CaptureService.php b/application/Espo/Tools/LeadCapture/CaptureService.php index d2de2c1f25f..6d3f9a7cf73 100644 --- a/application/Espo/Tools/LeadCapture/CaptureService.php +++ b/application/Espo/Tools/LeadCapture/CaptureService.php @@ -29,6 +29,7 @@ namespace Espo\Tools\LeadCapture; +use Espo\Core\Binding\Attributes\Qualify; use Espo\Core\Exceptions\Forbidden; use Espo\Core\Field\Link; use Espo\Core\FieldValidation\Exceptions\ValidationError; @@ -70,6 +71,7 @@ class CaptureService public function __construct( private EntityManager $entityManager, private FieldUtil $fieldUtil, + #[Qualify(Language::QUALIFIER_DEFAULT)] private Language $defaultLanguage, private HookManager $hookManager, private Log $log, diff --git a/application/Espo/Tools/LeadCapture/ConfirmationSender.php b/application/Espo/Tools/LeadCapture/ConfirmationSender.php index 9cc26282f3b..6c03cd6fdff 100644 --- a/application/Espo/Tools/LeadCapture/ConfirmationSender.php +++ b/application/Espo/Tools/LeadCapture/ConfirmationSender.php @@ -29,6 +29,7 @@ namespace Espo\Tools\LeadCapture; +use Espo\Core\Binding\Attributes\Qualify; use Espo\Core\Exceptions\Error; use Espo\Core\Mail\Account\GroupAccount\AccountFactory; use Espo\Core\Mail\EmailSender; @@ -54,6 +55,7 @@ class ConfirmationSender public function __construct( private EntityManager $entityManager, + #[Qualify(Language::QUALIFIER_DEFAULT)] private Language $defaultLanguage, private EmailSender $emailSender, private AccountFactory $accountFactory, diff --git a/application/Espo/Tools/LeadCapture/FormService.php b/application/Espo/Tools/LeadCapture/FormService.php index 3536a3cc308..dc8c8b4ea0f 100644 --- a/application/Espo/Tools/LeadCapture/FormService.php +++ b/application/Espo/Tools/LeadCapture/FormService.php @@ -29,6 +29,7 @@ namespace Espo\Tools\LeadCapture; +use Espo\Core\Binding\Attributes\Qualify; use Espo\Core\Exceptions\NotFound; use Espo\Core\ORM\Type\FieldType; use Espo\Core\Utils\Address\CountryDataProvider; @@ -54,6 +55,7 @@ public function __construct( private EntityManager $entityManager, private Config $config, private Metadata $metadata, + #[Qualify(Language::QUALIFIER_DEFAULT)] private Language $defaultLanguage, private CountryDataProvider $countryDataProvider, private LanguageService $languageService, diff --git a/application/Espo/Tools/LinkManager/LinkManager.php b/application/Espo/Tools/LinkManager/LinkManager.php index 0a1deed335e..8c7597d9584 100644 --- a/application/Espo/Tools/LinkManager/LinkManager.php +++ b/application/Espo/Tools/LinkManager/LinkManager.php @@ -29,6 +29,7 @@ namespace Espo\Tools\LinkManager; +use Espo\Core\Binding\Attributes\Qualify; use Espo\Core\DataManager; use Espo\Core\Exceptions\BadRequest; use Espo\Core\Exceptions\Conflict; @@ -69,6 +70,7 @@ class LinkManager public function __construct( private Metadata $metadata, private Language $language, + #[Qualify(Language::QUALIFIER_BASE)] private Language $baseLanguage, private DataManager $dataManager, private LinkHookProcessor $linkHookProcessor, diff --git a/application/Espo/Tools/Pdf/MassService.php b/application/Espo/Tools/Pdf/MassService.php index fafde7f9db9..a853d46115c 100644 --- a/application/Espo/Tools/Pdf/MassService.php +++ b/application/Espo/Tools/Pdf/MassService.php @@ -31,6 +31,7 @@ use DateTime; use Espo\Core\Acl; +use Espo\Core\Binding\Attributes\Qualify; use Espo\Core\Exceptions\BadRequest; use Espo\Core\Exceptions\Error; use Espo\Core\Exceptions\Forbidden; @@ -66,6 +67,7 @@ public function __construct( private DataLoaderManager $dataLoaderManager, private SelectBuilderFactory $selectBuilderFactory, private Builder $builder, + #[Qualify(Language::QUALIFIER_DEFAULT)] private Language $defaultLanguage, private JobSchedulerFactory $jobSchedulerFactory, private FileStorageManager $fileStorageManager, From 55af2c9212ee9806579e2b766384814363a7cb60 Mon Sep 17 00:00:00 2001 From: Yurii Date: Mon, 20 Jul 2026 12:54:05 +0300 Subject: [PATCH 47/87] Use qualifiers --- application/Espo/Tools/Layout/CustomLayoutService.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/application/Espo/Tools/Layout/CustomLayoutService.php b/application/Espo/Tools/Layout/CustomLayoutService.php index 0540e0b24c2..a85f4d2e5af 100644 --- a/application/Espo/Tools/Layout/CustomLayoutService.php +++ b/application/Espo/Tools/Layout/CustomLayoutService.php @@ -29,6 +29,7 @@ namespace Espo\Tools\Layout; +use Espo\Core\Binding\Attributes\Qualify; use Espo\Core\DataManager; use Espo\Core\Exceptions\BadRequest; use Espo\Core\Exceptions\Conflict; @@ -46,6 +47,7 @@ class CustomLayoutService public function __construct( private Metadata $metadata, private FileManager $fileManager, + #[Qualify(Language::QUALIFIER_BASE)] private Language $baseLanguage, private LayoutProvider $layoutProvider, private DataManager $dataManager From a618c3fc6caf9e4f8ede05a11c8103114b68a4c7 Mon Sep 17 00:00:00 2001 From: Yurii Date: Sat, 18 Jul 2026 20:08:55 +0300 Subject: [PATCH 48/87] Cache inteface --- .../Espo/Core/Acl/Table/DefaultTable.php | 12 +- .../Espo/Core/Container/ContainerBuilder.php | 18 +- application/Espo/Core/Hook/DataProvider.php | 8 +- application/Espo/Core/Utils/Autoload.php | 6 +- .../Core/Utils/Autoload/NamespaceLoader.php | 4 +- .../Espo/Core/Utils/Cache/CacheItem.php | 81 ++++++++ .../Espo/Core/Utils/Cache/DataCacheAccess.php | 31 +-- .../Cache/Exceptions/InvalidArgument.php | 39 ++++ .../Cache/Exceptions/PersistenceError.php | 38 ++++ .../Core/Utils/Cache/Exceptions/ReadError.php | 38 ++++ .../Core/Utils/Cache/FileCacheItemPool.php | 176 ++++++++++++++++++ application/Espo/Core/Utils/DataCache.php | 83 ++++----- .../Espo/Core/Utils/EmailFilterManager.php | 18 +- application/Espo/Core/Utils/Language.php | 39 ++-- .../Core/Utils/Metadata/OrmMetadataData.php | 8 +- application/Espo/Core/Utils/Module.php | 8 +- application/Espo/Core/Utils/Route.php | 6 +- .../App/Language/AclDependencyProvider.php | 6 +- .../App/Metadata/AclDependencyProvider.php | 6 +- .../Espo/Tools/LeadCapture/FormService.php | 12 +- application/Espo/Tools/OpenApi/Provider.php | 6 +- .../Tools/Pipeline/PipelineDataProvider.php | 6 +- composer.json | 3 +- composer.lock | 2 +- tests/unit/Espo/Core/Utils/DataCacheTest.php | 10 +- 25 files changed, 536 insertions(+), 128 deletions(-) create mode 100644 application/Espo/Core/Utils/Cache/CacheItem.php create mode 100644 application/Espo/Core/Utils/Cache/Exceptions/InvalidArgument.php create mode 100644 application/Espo/Core/Utils/Cache/Exceptions/PersistenceError.php create mode 100644 application/Espo/Core/Utils/Cache/Exceptions/ReadError.php create mode 100644 application/Espo/Core/Utils/Cache/FileCacheItemPool.php diff --git a/application/Espo/Core/Acl/Table/DefaultTable.php b/application/Espo/Core/Acl/Table/DefaultTable.php index 8c3aae51edb..afa155bf764 100644 --- a/application/Espo/Core/Acl/Table/DefaultTable.php +++ b/application/Espo/Core/Acl/Table/DefaultTable.php @@ -115,12 +115,18 @@ public function __construct( $this->cacheKey = $cacheKeyProvider->get(); + $cachedData = null; + if ($systemConfig->useCache() && $dataCache->has($this->cacheKey)) { - /** @var stdClass $cachedData */ + /** @var ?stdClass $cachedData */ $cachedData = $dataCache->get($this->cacheKey); - $this->data = $cachedData; - } else { + if ($cachedData !== null) { + $this->data = $cachedData; + } + } + + if ($cachedData === null) { $this->load(); if ($systemConfig->useCache()) { diff --git a/application/Espo/Core/Container/ContainerBuilder.php b/application/Espo/Core/Container/ContainerBuilder.php index c92be133d59..b6127608115 100644 --- a/application/Espo/Core/Container/ContainerBuilder.php +++ b/application/Espo/Core/Container/ContainerBuilder.php @@ -38,6 +38,7 @@ use Espo\Core\Binding\EspoBindingLoader; use Espo\Core\Loaders\ApplicationState as ApplicationStateLoader; +use Espo\Core\Utils\Cache\FileCacheItemPool; use Espo\Core\Utils\File\Manager as FileManager; use Espo\Core\Utils\Config\ConfigFileManager; use Espo\Core\Utils\Config; @@ -61,8 +62,6 @@ class ContainerBuilder private string $configClassName = Config::class; /** @var class-string */ private string $fileManagerClassName = FileManager::class; - /** @var class-string */ - private string $dataCacheClassName = DataCache::class; /** @var class-string */ private string $moduleClassName = Module::class; private ?BindingLoader $bindingLoader = null; @@ -157,17 +156,6 @@ public function withFileManagerClassName(string $fileManagerClassName): self return $this; } - /** - * @param class-string $dataCacheClassName - * @noinspection PhpUnused - */ - public function withDataCacheClassName(string $dataCacheClassName): self - { - $this->dataCacheClassName = $dataCacheClassName; - - return $this; - } - public function build(): ContainerInterface { $this->services['applicationParams'] = $this->params ?? new ApplicationParams(); @@ -186,9 +174,11 @@ public function build(): ContainerInterface ) ); + $cacheItemPool = new FileCacheItemPool($fileManager); + /** @var DataCache $dataCache */ $dataCache = $this->services['dataCache'] ?? ( - new $this->dataCacheClassName($fileManager) + new DataCache($cacheItemPool) ); $useCache = $config->get('useCache') ?? false; diff --git a/application/Espo/Core/Hook/DataProvider.php b/application/Espo/Core/Hook/DataProvider.php index 2d655af91dd..2a064956f4c 100644 --- a/application/Espo/Core/Hook/DataProvider.php +++ b/application/Espo/Core/Hook/DataProvider.php @@ -82,12 +82,14 @@ public function get(): array private function load(): void { if ($this->systemConfig->useCache() && $this->dataCache->has($this->cacheKey)) { - /** @var array> $cachedData */ + /** @var ?array> $cachedData */ $cachedData = $this->dataCache->get($this->cacheKey); - $this->data = $cachedData; + if ($cachedData !== null) { + $this->data = $cachedData; - return; + return; + } } $data = $this->readHookData($this->pathProvider->getCustom() . 'Hooks'); diff --git a/application/Espo/Core/Utils/Autoload.php b/application/Espo/Core/Utils/Autoload.php index d1a87248260..59f5b033ce8 100644 --- a/application/Espo/Core/Utils/Autoload.php +++ b/application/Espo/Core/Utils/Autoload.php @@ -75,9 +75,11 @@ private function init(): void /** @var ?array $data */ $data = $this->dataCache->get($this->cacheKey); - $this->data = $data; + if ($data !== null) { + $this->data = $data; - return; + return; + } } $this->data = $this->loadData(); diff --git a/application/Espo/Core/Utils/Autoload/NamespaceLoader.php b/application/Espo/Core/Utils/Autoload/NamespaceLoader.php index 3a3f2dd8a40..e5794e26f04 100644 --- a/application/Espo/Core/Utils/Autoload/NamespaceLoader.php +++ b/application/Espo/Core/Utils/Autoload/NamespaceLoader.php @@ -240,12 +240,10 @@ private function getVendorNamespaces(string $path): array /** @var ?array $cachedData */ $cachedData = $this->dataCache->get($this->cacheKey); - $this->vendorNamespaces = $cachedData; + $this->vendorNamespaces = $cachedData ?? []; } } - assert($this->vendorNamespaces !== null); - if (!array_key_exists($path, $this->vendorNamespaces)) { $vendorPath = $this->findVendorPath($path); diff --git a/application/Espo/Core/Utils/Cache/CacheItem.php b/application/Espo/Core/Utils/Cache/CacheItem.php new file mode 100644 index 00000000000..90729faddaf --- /dev/null +++ b/application/Espo/Core/Utils/Cache/CacheItem.php @@ -0,0 +1,81 @@ +. + * + * 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\Cache; + +use DateInterval; +use DateTimeInterface; +use Psr\Cache\CacheItemInterface; + +class CacheItem implements CacheItemInterface +{ + public function __construct( + private string $key, + private mixed $value, + private bool $isHit = false, + ) {} + + public function getKey(): string + { + return $this->key; + } + + public function get(): mixed + { + return $this->value; + } + + public function isHit(): bool + { + return $this->isHit; + } + + public function set(mixed $value): static + { + $this->value = $value; + + return $this; + } + + /** + * Not supported. + */ + public function expiresAt(?DateTimeInterface $expiration): static + { + return $this; + } + + /** + * Not supported. + */ + public function expiresAfter(DateInterval|int|null $time): static + { + return $this; + } +} diff --git a/application/Espo/Core/Utils/Cache/DataCacheAccess.php b/application/Espo/Core/Utils/Cache/DataCacheAccess.php index d91f155150c..2aa2bd49b11 100644 --- a/application/Espo/Core/Utils/Cache/DataCacheAccess.php +++ b/application/Espo/Core/Utils/Cache/DataCacheAccess.php @@ -30,6 +30,7 @@ namespace Espo\Core\Utils\Cache; use Closure; +use Espo\Core\Utils\Cache\Exceptions\ReadError; use Espo\Core\Utils\Config\SystemConfig; use Espo\Core\Utils\DataCache; use Espo\Core\Utils\Log; @@ -40,6 +41,7 @@ * @internal * @since 10.1.0 * @template T of array | stdClass = array | stdClass + * @todo Test. */ class DataCacheAccess { @@ -140,24 +142,31 @@ private function loadFromCache(): void { $key = $this->key ?? throw new LogicException(); - $data = $this->dataCache->tryGet($key); + try { + $data = $this->dataCache->get($key); + } catch (ReadError $e) { + $this->log->warning("Corrupted cache data by key '{key}'.", [ + 'exception' => $e, + 'key' => $key, + ]); - if (is_array($data) || $data instanceof stdClass) { - /** @var T $data */ + $this->dataCache->clear($key); - if ($this->validityChecker && !($this->validityChecker)($data)) { - $this->data = null; + return; + } - return; - } + if ($data === null) { + return; + } - $this->data = $data; + /** @var T $data */ + + if ($this->validityChecker && !($this->validityChecker)($data)) { + $this->data = null; return; } - $this->log->warning("Corrupted cache data in '$key'."); - - $this->dataCache->clear($key); + $this->data = $data; } } diff --git a/application/Espo/Core/Utils/Cache/Exceptions/InvalidArgument.php b/application/Espo/Core/Utils/Cache/Exceptions/InvalidArgument.php new file mode 100644 index 00000000000..5be77e21efe --- /dev/null +++ b/application/Espo/Core/Utils/Cache/Exceptions/InvalidArgument.php @@ -0,0 +1,39 @@ +. + * + * 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\Cache\Exceptions; + +use Psr\Cache\InvalidArgumentException as InvalidArgumentExceptionInterface; +use RuntimeException; + +/** + * @since 10.1.0 + */ +class InvalidArgument extends RuntimeException implements InvalidArgumentExceptionInterface +{} diff --git a/application/Espo/Core/Utils/Cache/Exceptions/PersistenceError.php b/application/Espo/Core/Utils/Cache/Exceptions/PersistenceError.php new file mode 100644 index 00000000000..73fb7d54814 --- /dev/null +++ b/application/Espo/Core/Utils/Cache/Exceptions/PersistenceError.php @@ -0,0 +1,38 @@ +. + * + * 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\Cache\Exceptions; + +use RuntimeException; + +/** + * @since 10.1.0 + */ +class PersistenceError extends RuntimeException +{} diff --git a/application/Espo/Core/Utils/Cache/Exceptions/ReadError.php b/application/Espo/Core/Utils/Cache/Exceptions/ReadError.php new file mode 100644 index 00000000000..d0c32568696 --- /dev/null +++ b/application/Espo/Core/Utils/Cache/Exceptions/ReadError.php @@ -0,0 +1,38 @@ +. + * + * 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\Cache\Exceptions; + +use RuntimeException; + +/** + * @since 10.1.0 + */ +class ReadError extends RuntimeException +{} diff --git a/application/Espo/Core/Utils/Cache/FileCacheItemPool.php b/application/Espo/Core/Utils/Cache/FileCacheItemPool.php new file mode 100644 index 00000000000..b3802368982 --- /dev/null +++ b/application/Espo/Core/Utils/Cache/FileCacheItemPool.php @@ -0,0 +1,176 @@ +. + * + * 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\Cache; + +use Espo\Core\Utils\Cache\Exceptions\InvalidArgument; +use Espo\Core\Utils\File\Exceptions\FileError; +use Espo\Core\Utils\File\Manager as FileManager; +use InvalidArgumentException; +use Psr\Cache\CacheItemInterface; +use Psr\Cache\CacheItemPoolInterface; + +/** + * Supports only array and stdClass values. + * + * @since 10.1.0 + */ +class FileCacheItemPool implements CacheItemPoolInterface +{ + private string $cacheDir = 'data/cache/application/'; + + public function __construct(private FileManager $fileManager) + {} + + /** + * @throws InvalidArgument + */ + public function getItem(string $key): CacheItem + { + $file = $this->getFile($key); + + try { + $value = $this->fileManager->getPhpSafeContents($file); + } catch (FileError) { + return new CacheItem( + key: $key, + value: null, + isHit: false, + ); + } + + return new CacheItem( + key: $key, + value: $value, + isHit: true, + ); + } + + /** + * @return iterable + */ + public function getItems(array $keys = []): iterable + { + $values = []; + + foreach ($keys as $key) { + $values[] = $this->getItem($key); + } + + return $values; + } + + /** + * @throws InvalidArgument + */ + public function hasItem(string $key): bool + { + $file = $this->getFile($key); + + return $this->fileManager->isFile($file); + } + + /** + * @inheritDoc + */ + public function clear(): bool + { + return $this->fileManager->removeInDir($this->cacheDir); + } + + /** + * @inheritDoc + * @throws InvalidArgument + */ + public function deleteItem(string $key): bool + { + $file = $this->getFile($key); + + return $this->fileManager->removeFile($file); + } + + /** + * @inheritDoc + */ + public function deleteItems(array $keys): bool + { + $result = true; + + foreach ($keys as $key) { + $result &= $this->deleteItem($key); + } + + return (bool) $result; + } + + /** + * Persists a cache immediately. + * + * @todo Throw an error in DataCache if false. + */ + public function save(CacheItemInterface $item): bool + { + $file = $this->getFile($item->getKey()); + + $result = $this->fileManager->putPhpContents($file, $item->get(), true, true); + + if ($result === false) { + return false; + } + + return true; + } + + /** + * Sets a cache item to be persisted later. + */ + public function saveDeferred(CacheItemInterface $item): bool + { + return $this->save($item); + } + + public function commit(): bool + { + return true; + } + + private function getFile(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'; + } +} diff --git a/application/Espo/Core/Utils/DataCache.php b/application/Espo/Core/Utils/DataCache.php index f7509c8c90b..2fcdae50967 100644 --- a/application/Espo/Core/Utils/DataCache.php +++ b/application/Espo/Core/Utils/DataCache.php @@ -29,60 +29,67 @@ namespace Espo\Core\Utils; -use Espo\Core\Utils\File\Exceptions\FileError; -use Espo\Core\Utils\File\Manager as FileManager; - +use Espo\Core\Utils\Cache\CacheItem; +use Espo\Core\Utils\Cache\Exceptions\InvalidArgument; +use Espo\Core\Utils\Cache\Exceptions\PersistenceError; +use Espo\Core\Utils\Cache\Exceptions\ReadError; +use Espo\Core\Utils\Cache\FileCacheItemPool; use InvalidArgumentException; -use RuntimeException; use stdClass; class DataCache { - protected string $cacheDir = 'data/cache/application/'; - - public function __construct(protected FileManager $fileManager) - {} + public function __construct( + private FileCacheItemPool $fileCacheItemPool, + ) {} /** * Whether is cached. */ public function has(string $key): bool { - $cacheFile = $this->getCacheFile($key); - - return $this->fileManager->isFile($cacheFile); + return $this->fileCacheItemPool->hasItem($key); } /** - * Get a stored value. + * Get a stored value. Returns null if not hit. * - * @return array|stdClass - * @throws FileError + * @return array|stdClass|null + * @throws ReadError If data is corrupted. */ - public function get(string $key) + public function get(string $key): array|stdClass|null { - $cacheFile = $this->getCacheFile($key); + $item = $this->fileCacheItemPool->getItem($key); + + if (!$item->isHit()) { + return null; + } + + $value = $item->get(); + + if (!is_array($value) && !$value instanceof stdClass) { + throw new ReadError("Bad cache data by key '$key'."); + } - return $this->fileManager->getPhpSafeContents($cacheFile); + /** @var array|stdClass */ + return $value; } /** - * Try to get a stored value. Returns null if does not exist. + * Try to get a stored value. Does not throw ReadError. * * @return array|stdClass|null * @since 9.3.0 */ - public function tryGet(string $key) + public function tryGet(string $key): array|stdClass|null { if (!$this->has($key)) { return null; } - $cacheFile = $this->getCacheFile($key); - try { - return $this->fileManager->getPhpSafeContents($cacheFile); - } catch (FileError) { + return $this->get($key); + } catch (ReadError) { return null; } } @@ -91,6 +98,7 @@ public function tryGet(string $key) * Store in cache. * * @param array|stdClass $data + * @throws PersistenceError */ public function store(string $key, $data): void { @@ -100,23 +108,26 @@ public function store(string $key, $data): void throw new InvalidArgumentException("Bad cache data type."); } - $cacheFile = $this->getCacheFile($key); + $item = new CacheItem( + key: $key, + value: $data, + ); - $result = $this->fileManager->putPhpContents($cacheFile, $data, true, true); + $result = $this->fileCacheItemPool->save($item); if ($result === false) { - throw new RuntimeException("Could not store '$key'."); + throw new PersistenceError("Could not store '$key'."); } } /** * Removes in cache. + * + * @throws InvalidArgument */ public function clear(string $key): void { - $cacheFile = $this->getCacheFile($key); - - $this->fileManager->removeFile($cacheFile); + $this->fileCacheItemPool->deleteItem($key); } /** @@ -131,18 +142,4 @@ private function checkDataIsValid($data) 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'; - } } diff --git a/application/Espo/Core/Utils/EmailFilterManager.php b/application/Espo/Core/Utils/EmailFilterManager.php index ef8acd42478..cf3fca53eb5 100644 --- a/application/Espo/Core/Utils/EmailFilterManager.php +++ b/application/Espo/Core/Utils/EmailFilterManager.php @@ -86,11 +86,15 @@ private function get(string $userId): array $cacheKey = $this->composeCacheKey($userId); if ($this->useCache && $this->dataCache->has($cacheKey)) { - $this->data[$userId] = $this->loadFromCache($cacheKey); + $cached = $this->loadFromCache($cacheKey); - $this->setCacheVersionNumber($userId); + if ($cached !== null) { + $this->data[$userId] = $cached; - return $this->data[$userId]; + $this->setCacheVersionNumber($userId); + + return $this->data[$userId]; + } } $this->data[$userId] = $this->fetch($userId); @@ -136,13 +140,17 @@ private function fetch(string $userId): array } /** - * @return EmailFilter[] + * @return ?EmailFilter[] */ - private function loadFromCache(string $cacheKey): array + private function loadFromCache(string $cacheKey): ?array { /** @var stdClass[] $dataList */ $dataList = $this->dataCache->get($cacheKey); + if ($dataList === null) { + return null; + } + /** @var EmailFilter[] $list */ $list = []; diff --git a/application/Espo/Core/Utils/Language.php b/application/Espo/Core/Utils/Language.php index 5c03b62a46f..06154bf07a5 100644 --- a/application/Espo/Core/Utils/Language.php +++ b/application/Espo/Core/Utils/Language.php @@ -451,34 +451,35 @@ private function getLanguageData(string $language, bool $reload = false): array $cacheKey = $this->getCacheKey($language); - if (!$this->useCache || !$this->dataCache->has($cacheKey) || $reload) { - $readerParams = ResourceReaderParams - ::create() - ->withNoCustom($this->noCustom); - - $path = str_replace('{language}', $language, $this->resourcePath); + if (!$reload && $this->useCache && $this->dataCache->has($cacheKey)) { + /** @var ?array $cachedData */ + $cachedData = $this->dataCache->get($cacheKey); - $data = $this->resourceReader->readAsArray($path, $readerParams); + if ($cachedData !== null) { + $this->data[$language] = $cachedData; - if ($language !== $this->defaultLanguage && !$this->noFallback) { - /** @var array> $data */ - $data = Util::merge($this->getDefaultLanguageData($reload), $data); + return $cachedData; } + } - $this->data[$language] = $data; + $readerParams = ResourceReaderParams::create() + ->withNoCustom($this->noCustom); - if ($this->useCache) { - $this->dataCache->store($cacheKey, $data); - } + $path = str_replace('{language}', $language, $this->resourcePath); + + $data = $this->resourceReader->readAsArray($path, $readerParams); + + if ($language !== $this->defaultLanguage && !$this->noFallback) { + /** @var array> $data */ + $data = Util::merge($this->getDefaultLanguageData($reload), $data); } - if ($this->useCache) { - /** @var array $cachedData */ - $cachedData = $this->dataCache->get($cacheKey); + $this->data[$language] = $data; - $this->data[$language] = $cachedData; + if ($this->useCache) { + $this->dataCache->store($cacheKey, $data); } - return $this->data[$language] ?? []; + return $data; } } diff --git a/application/Espo/Core/Utils/Metadata/OrmMetadataData.php b/application/Espo/Core/Utils/Metadata/OrmMetadataData.php index 77a19426700..64b2a7ce5e7 100644 --- a/application/Espo/Core/Utils/Metadata/OrmMetadataData.php +++ b/application/Espo/Core/Utils/Metadata/OrmMetadataData.php @@ -88,12 +88,14 @@ private function getDataInternal(bool $reload = false): array } if ($this->useCache && $this->dataCache->has($this->cacheKey) && !$reload) { - /** @var array> $data */ + /** @var ?array> $data */ $data = $this->dataCache->get($this->cacheKey); - $this->data = $data; + if ($data !== null) { + $this->data = $data; - return $this->data; + return $this->data; + } } $this->data = $this->getConverter()->process(); diff --git a/application/Espo/Core/Utils/Module.php b/application/Espo/Core/Utils/Module.php index 7085fd5dc18..8f36200fe45 100644 --- a/application/Espo/Core/Utils/Module.php +++ b/application/Espo/Core/Utils/Module.php @@ -87,12 +87,14 @@ private function init(): void $this->dataCache && $this->dataCache->has($this->cacheKey) ) { - /** @var array> $data */ + /** @var ?array> $data */ $data = $this->dataCache->get($this->cacheKey); - $this->data = $data; + if ($data !== null) { + $this->data = $data; - return; + return; + } } $this->data = $this->loadData(); diff --git a/application/Espo/Core/Utils/Route.php b/application/Espo/Core/Utils/Route.php index aebfe2d6cf2..9cf4c0bb346 100644 --- a/application/Espo/Core/Utils/Route.php +++ b/application/Espo/Core/Utils/Route.php @@ -97,9 +97,11 @@ private function init(): void /** @var ?(RouteArrayShape[]) $data */ $data = $this->dataCache->get($this->cacheKey); - $this->data = $data; + if ($data !== null) { + $this->data = $data; - return; + return; + } } $this->data = $this->unify(); diff --git a/application/Espo/Tools/App/Language/AclDependencyProvider.php b/application/Espo/Tools/App/Language/AclDependencyProvider.php index b94f2b9c006..96fd6d2e270 100644 --- a/application/Espo/Tools/App/Language/AclDependencyProvider.php +++ b/application/Espo/Tools/App/Language/AclDependencyProvider.php @@ -78,10 +78,12 @@ public function get(): array private function loadData(): array { if ($this->useCache && $this->dataCache->has(self::CACHE_KEY)) { - /** @var array[] $raw */ + /** @var ?array[] $raw */ $raw = $this->dataCache->get(self::CACHE_KEY); - return $this->buildFromRaw($raw); + if ($raw !== null) { + return $this->buildFromRaw($raw); + } } return $this->buildData(); diff --git a/application/Espo/Tools/App/Metadata/AclDependencyProvider.php b/application/Espo/Tools/App/Metadata/AclDependencyProvider.php index 4ce03807e89..e9ba71cc3c9 100644 --- a/application/Espo/Tools/App/Metadata/AclDependencyProvider.php +++ b/application/Espo/Tools/App/Metadata/AclDependencyProvider.php @@ -78,10 +78,12 @@ public function get(): array private function loadData(): array { if ($this->useCache && $this->dataCache->has(self::CACHE_KEY)) { - /** @var array[] $raw */ + /** @var ?array[] $raw */ $raw = $this->dataCache->get(self::CACHE_KEY); - return $this->buildFromRaw($raw); + if ($raw !== null) { + return $this->buildFromRaw($raw); + } } return $this->buildData(); diff --git a/application/Espo/Tools/LeadCapture/FormService.php b/application/Espo/Tools/LeadCapture/FormService.php index dc8c8b4ea0f..5258b2c620a 100644 --- a/application/Espo/Tools/LeadCapture/FormService.php +++ b/application/Espo/Tools/LeadCapture/FormService.php @@ -91,7 +91,11 @@ private function getDataInternal(LeadCapture $leadCapture): array $cacheKey = $this->getCacheKey($leadCapture); if ($this->systemConfig->useCache() && $this->dataCache->has($cacheKey)) { - return $this->getFromCache($cacheKey); + $cached = $this->getFromCache($cacheKey); + + if ($cached !== null) { + return $cached; + } } $data = $this->prepareData($leadCapture); @@ -389,11 +393,11 @@ private function getCacheKey(LeadCapture $leadCapture): string } /** - * @return array + * @return ?array */ - private function getFromCache(string $cacheKey): array + private function getFromCache(string $cacheKey): ?array { - /** @var array */ + /** @var ?array */ return $this->dataCache->get($cacheKey); } diff --git a/application/Espo/Tools/OpenApi/Provider.php b/application/Espo/Tools/OpenApi/Provider.php index 09f2b2a556a..0752d37e52e 100644 --- a/application/Espo/Tools/OpenApi/Provider.php +++ b/application/Espo/Tools/OpenApi/Provider.php @@ -90,11 +90,13 @@ private function getData(Params $params): stdClass if ($this->dataCache->has($cacheKey)) { $data = $this->dataCache->get($cacheKey); - if (!$data instanceof stdClass) { + if ($data !== null && !$data instanceof stdClass) { throw new RuntimeException("Corrupted OpenAPI spec cache file."); } - return $data; + if ($data !== null) { + return $data; + } } $data = $this->buildData($params); diff --git a/application/Espo/Tools/Pipeline/PipelineDataProvider.php b/application/Espo/Tools/Pipeline/PipelineDataProvider.php index 22c86eaaf2f..5f6b0c347de 100644 --- a/application/Espo/Tools/Pipeline/PipelineDataProvider.php +++ b/application/Espo/Tools/Pipeline/PipelineDataProvider.php @@ -81,7 +81,7 @@ public function get(): array if ($this->systemConfig->useCache()) { $data = $this->getFromCache(); - if (!$data) { + if ($data === null) { $store = true; } } @@ -300,6 +300,10 @@ private function tryGetFromCache(): ?array { $data = $this->dataCache->get(self::CACHE_KEY); + if ($data === null) { + return null; + } + if (!$data instanceof stdClass) { $this->log->warning("Bad pipeline cache."); diff --git a/composer.json b/composer.json index 69fddaf3648..4b059737b00 100644 --- a/composer.json +++ b/composer.json @@ -58,7 +58,8 @@ "guzzlehttp/guzzle": "^7.10", "devtheorem/php-handlebars": "^1.0", "php-amqplib/php-amqplib": "^3.7", - "predis/predis": "^3.5" + "predis/predis": "^3.5", + "psr/cache": "^3.0" }, "require-dev": { "phpunit/phpunit": "^11.5", diff --git a/composer.lock b/composer.lock index a7d45b5359a..0ab3cdb7e94 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "1ae60dc55489013607247b563b8935f3", + "content-hash": "038edf6480a482ceac6acb6a643eee35", "packages": [ { "name": "async-aws/core", diff --git a/tests/unit/Espo/Core/Utils/DataCacheTest.php b/tests/unit/Espo/Core/Utils/DataCacheTest.php index 1c9cef4a707..b8b4cfb86cc 100644 --- a/tests/unit/Espo/Core/Utils/DataCacheTest.php +++ b/tests/unit/Espo/Core/Utils/DataCacheTest.php @@ -29,23 +29,25 @@ namespace tests\unit\Espo\Core\Utils; +use Espo\Core\Utils\Cache\FileCacheItemPool; use Espo\Core\Utils\DataCache; use Espo\Core\Utils\File\Manager as FileManager; - use PHPUnit\Framework\TestCase; use RuntimeException; use InvalidArgumentException; class DataCacheTest extends TestCase { - private $fileManager; - private $dataCache; + private ?FileManager $fileManager = null; + private ?DataCache $dataCache = null; protected function setUp() : void { $this->fileManager = $this->createMock(FileManager::class); - $this->dataCache = new DataCache($this->fileManager); + $pool = new FileCacheItemPool($this->fileManager); + + $this->dataCache = new DataCache($pool); } public function testHasTrue() From fa9d05439e0fe302c04e0c8d558ebf86b97d191c Mon Sep 17 00:00:00 2001 From: Yurii Date: Mon, 20 Jul 2026 16:15:17 +0300 Subject: [PATCH 49/87] System data cache service --- application/Espo/Binding.php | 43 ++++++++++++++ .../Espo/Classes/AppInfo/Container.php | 1 + .../Espo/Core/Container/ContainerBuilder.php | 5 +- application/Espo/Core/Di/DataCacheSetter.php | 3 + application/Espo/Core/Hook/DataProvider.php | 2 + application/Espo/Core/Utils/Autoload.php | 2 + .../Core/Utils/Autoload/NamespaceLoader.php | 2 + .../Utils/Cache/DataCacheServiceLoader.php | 56 +++++++++++++++++++ .../Core/Utils/Cache/DataCacheServiceName.php | 39 +++++++++++++ application/Espo/Core/Utils/DataCache.php | 41 ++++++++------ application/Espo/Core/Utils/File/ClassMap.php | 2 + application/Espo/Core/Utils/Language.php | 2 + application/Espo/Core/Utils/Metadata.php | 3 + .../Core/Utils/Metadata/OrmMetadataData.php | 2 + application/Espo/Core/Utils/Route.php | 2 + .../metadata/app/containerServices.json | 3 + .../App/Language/AclDependencyProvider.php | 2 + .../App/Metadata/AclDependencyProvider.php | 2 + .../Espo/Tools/LabelManager/LabelManager.php | 4 +- tests/integration/Core/BaseTestCase.php | 2 + .../Espo/Core/Binding/BindingTest.php | 42 ++++++++++++++ tests/unit/Espo/Core/Utils/DataCacheTest.php | 3 +- 22 files changed, 241 insertions(+), 22 deletions(-) create mode 100644 application/Espo/Core/Utils/Cache/DataCacheServiceLoader.php create mode 100644 application/Espo/Core/Utils/Cache/DataCacheServiceName.php diff --git a/application/Espo/Binding.php b/application/Espo/Binding.php index 69417feb1a3..83cc5562a5e 100644 --- a/application/Espo/Binding.php +++ b/application/Espo/Binding.php @@ -30,9 +30,17 @@ namespace Espo; use Espo\Core\Binding\Binder; +use Espo\Core\Binding\BindingContainerBuilder; use Espo\Core\Binding\BindingProcessor; use Espo\Core\Binding\Key\NamedClassKey; use Espo\Core\Binding\Key\QualifiedClassKey; +use Espo\Core\Container; +use Espo\Core\InjectableFactory; +use Espo\Core\Utils\Cache\DataCacheAccess; +use Espo\Core\Utils\Cache\DataCacheServiceName as DataCacheServiceName; +use Espo\Core\Utils\Cache\FileCacheItemPool; +use Espo\Core\Utils\DataCache; +use Psr\Cache\CacheItemPoolInterface; /** * Default binding for the dependency injection framework. Custom binding should be set up in @@ -89,6 +97,8 @@ private function bindServices(Binder $binder): void 'fileManager' ); + $this->bindDataCacheServices($binder); + $binder->bindService( 'Espo\\ORM\\EntityManager', 'entityManager' @@ -332,6 +342,11 @@ private function bindCore(Binder $binder): void 'Espo\\Core\\Job\\Processing\\RabbitMq\\Consumer', ); }); + + $binder->bindImplementation( + CacheItemPoolInterface::class, + FileCacheItemPool::class + ); } private function bindMisc(Binder $binder): void @@ -451,4 +466,32 @@ private function bindEmailAccount(Binder $binder): void 'Espo\\Core\\Mail\\Account\\GroupAccount\\StorageFactory' ); } + + private function bindDataCacheServices(Binder $binder): void + { + $binder->bindService( + DataCache::class, + DataCacheServiceName::APPLICATION + ); + + $binder->bindService( + QualifiedClassKey::create(DataCache::class, DataCache::QUALIFIER_SYSTEM), + DataCacheServiceName::SYSTEM + ); + + $binder->bindCallback( + QualifiedClassKey::create(DataCacheAccess::class, DataCache::QUALIFIER_SYSTEM), + function (InjectableFactory $injectableFactory, Container $container) { + return $injectableFactory->createWithBinding( + DataCacheAccess::class, + BindingContainerBuilder::create() + ->bindInstance( + DataCache::class, + $container->get(DataCacheServiceName::SYSTEM) + ) + ->build() + ); + } + ); + } } diff --git a/application/Espo/Classes/AppInfo/Container.php b/application/Espo/Classes/AppInfo/Container.php index 96b65394957..6424b7f529c 100644 --- a/application/Espo/Classes/AppInfo/Container.php +++ b/application/Espo/Classes/AppInfo/Container.php @@ -53,6 +53,7 @@ public function get(Params $params): string 'dataManager', 'metadata', 'user', + 'systemDataCache', ]; /** @var string[] $fileList */ diff --git a/application/Espo/Core/Container/ContainerBuilder.php b/application/Espo/Core/Container/ContainerBuilder.php index b6127608115..4be533ec51b 100644 --- a/application/Espo/Core/Container/ContainerBuilder.php +++ b/application/Espo/Core/Container/ContainerBuilder.php @@ -39,6 +39,7 @@ use Espo\Core\Loaders\ApplicationState as ApplicationStateLoader; use Espo\Core\Utils\Cache\FileCacheItemPool; +use Espo\Core\Utils\Cache\DataCacheServiceName; use Espo\Core\Utils\File\Manager as FileManager; use Espo\Core\Utils\Config\ConfigFileManager; use Espo\Core\Utils\Config; @@ -177,7 +178,7 @@ public function build(): ContainerInterface $cacheItemPool = new FileCacheItemPool($fileManager); /** @var DataCache $dataCache */ - $dataCache = $this->services['dataCache'] ?? ( + $dataCache = $this->services[DataCacheServiceName::SYSTEM] ?? ( new DataCache($cacheItemPool) ); @@ -192,7 +193,7 @@ public function build(): ContainerInterface $this->services['config'] = $config; $this->services['fileManager'] = $fileManager; - $this->services['dataCache'] = $dataCache; + $this->services[DataCacheServiceName::SYSTEM] = $dataCache; $this->services['module'] = $module; $this->services['systemConfig'] = $systemConfig; diff --git a/application/Espo/Core/Di/DataCacheSetter.php b/application/Espo/Core/Di/DataCacheSetter.php index 4c7c9707416..857b02565c5 100644 --- a/application/Espo/Core/Di/DataCacheSetter.php +++ b/application/Espo/Core/Di/DataCacheSetter.php @@ -31,6 +31,9 @@ use Espo\Core\Utils\DataCache; +/** + * @phpstan-ignore-next-line trait.unused + */ trait DataCacheSetter { /** diff --git a/application/Espo/Core/Hook/DataProvider.php b/application/Espo/Core/Hook/DataProvider.php index 2a064956f4c..db9f0e81eac 100644 --- a/application/Espo/Core/Hook/DataProvider.php +++ b/application/Espo/Core/Hook/DataProvider.php @@ -29,6 +29,7 @@ namespace Espo\Core\Hook; +use Espo\Core\Binding\Attributes\Qualify; use Espo\Core\Utils\Config\SystemConfig; use Espo\Core\Utils\DataCache; use Espo\Core\Utils\File\Manager as FileManager; @@ -63,6 +64,7 @@ public function __construct( private SystemConfig $systemConfig, private FileManager $fileManager, private PathProvider $pathProvider, + #[Qualify(DataCache::QUALIFIER_SYSTEM)] private DataCache $dataCache, private Metadata $metadata, ) {} diff --git a/application/Espo/Core/Utils/Autoload.php b/application/Espo/Core/Utils/Autoload.php index 59f5b033ce8..ceccb65cd00 100644 --- a/application/Espo/Core/Utils/Autoload.php +++ b/application/Espo/Core/Utils/Autoload.php @@ -29,6 +29,7 @@ namespace Espo\Core\Utils; +use Espo\Core\Binding\Attributes\Qualify; use Espo\Core\Utils\Autoload\Loader; use Espo\Core\Utils\Config\SystemConfig; use Espo\Core\Utils\File\Manager as FileManager; @@ -46,6 +47,7 @@ class Autoload public function __construct( private Metadata $metadata, + #[Qualify(DataCache::QUALIFIER_SYSTEM)] private DataCache $dataCache, private FileManager $fileManager, private Loader $loader, diff --git a/application/Espo/Core/Utils/Autoload/NamespaceLoader.php b/application/Espo/Core/Utils/Autoload/NamespaceLoader.php index e5794e26f04..cc94fd1f891 100644 --- a/application/Espo/Core/Utils/Autoload/NamespaceLoader.php +++ b/application/Espo/Core/Utils/Autoload/NamespaceLoader.php @@ -29,6 +29,7 @@ namespace Espo\Core\Utils\Autoload; +use Espo\Core\Binding\Attributes\Qualify; use Espo\Core\Utils\Config\SystemConfig; use Espo\Core\Utils\DataCache; use Espo\Core\Utils\File\Manager as FileManager; @@ -68,6 +69,7 @@ class NamespaceLoader private ClassLoader $classLoader; public function __construct( + #[Qualify(DataCache::QUALIFIER_SYSTEM)] private DataCache $dataCache, private FileManager $fileManager, private Log $log, diff --git a/application/Espo/Core/Utils/Cache/DataCacheServiceLoader.php b/application/Espo/Core/Utils/Cache/DataCacheServiceLoader.php new file mode 100644 index 00000000000..35d71062530 --- /dev/null +++ b/application/Espo/Core/Utils/Cache/DataCacheServiceLoader.php @@ -0,0 +1,56 @@ +. + * + * 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\Cache; + +use Espo\Core\Container; +use Espo\Core\Container\Loader; +use Espo\Core\Utils\DataCache; +use RuntimeException; + +/** + * @noinspection PhpUnused + */ +class DataCacheServiceLoader implements Loader +{ + public function __construct( + private Container $container, + ) {} + + public function load(): DataCache + { + $service = $this->container->get(DataCacheServiceName::SYSTEM); + + if (!$service instanceof DataCache) { + throw new RuntimeException("Unexpected 'dataCache' service instance."); + } + + return $service; + } +} diff --git a/application/Espo/Core/Utils/Cache/DataCacheServiceName.php b/application/Espo/Core/Utils/Cache/DataCacheServiceName.php new file mode 100644 index 00000000000..219c3942be6 --- /dev/null +++ b/application/Espo/Core/Utils/Cache/DataCacheServiceName.php @@ -0,0 +1,39 @@ +. + * + * 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\Cache; + +/** + * @since 10.1.0 + */ +class DataCacheServiceName +{ + public const string SYSTEM = 'systemDataCache'; + public const string APPLICATION = 'dataCache'; +} diff --git a/application/Espo/Core/Utils/DataCache.php b/application/Espo/Core/Utils/DataCache.php index 2fcdae50967..7cd309ec626 100644 --- a/application/Espo/Core/Utils/DataCache.php +++ b/application/Espo/Core/Utils/DataCache.php @@ -33,14 +33,21 @@ use Espo\Core\Utils\Cache\Exceptions\InvalidArgument; use Espo\Core\Utils\Cache\Exceptions\PersistenceError; use Espo\Core\Utils\Cache\Exceptions\ReadError; -use Espo\Core\Utils\Cache\FileCacheItemPool; -use InvalidArgumentException; +use Psr\Cache\CacheItemPoolInterface; +use Psr\Cache\InvalidArgumentException; use stdClass; class DataCache { + /** + * DI qualifier. Can be applied to the DataCache and DataCacheAccess dependencies. + * + * @since 10.1.0 + */ + public const string QUALIFIER_SYSTEM = 'system'; + public function __construct( - private FileCacheItemPool $fileCacheItemPool, + private CacheItemPoolInterface $pool, ) {} /** @@ -48,7 +55,11 @@ public function __construct( */ public function has(string $key): bool { - return $this->fileCacheItemPool->hasItem($key); + try { + return $this->pool->hasItem($key); + } catch (InvalidArgumentException $e) { + throw new InvalidArgument(previous: $e); + } } /** @@ -59,7 +70,11 @@ public function has(string $key): bool */ public function get(string $key): array|stdClass|null { - $item = $this->fileCacheItemPool->getItem($key); + try { + $item = $this->pool->getItem($key); + } catch (InvalidArgumentException $e) { + throw new InvalidArgument(previous: $e); + } if (!$item->isHit()) { return null; @@ -105,7 +120,7 @@ public function store(string $key, $data): void /** @phpstan-var mixed $data */ if (!$this->checkDataIsValid($data)) { - throw new InvalidArgumentException("Bad cache data type."); + throw new InvalidArgument("Bad cache data type."); } $item = new CacheItem( @@ -113,7 +128,7 @@ public function store(string $key, $data): void value: $data, ); - $result = $this->fileCacheItemPool->save($item); + $result = $this->pool->save($item); if ($result === false) { throw new PersistenceError("Could not store '$key'."); @@ -127,18 +142,12 @@ public function store(string $key, $data): void */ public function clear(string $key): void { - $this->fileCacheItemPool->deleteItem($key); + $this->pool->deleteItem($key); } - /** - * @param mixed $data - * @return bool - */ - private function checkDataIsValid($data) + private function checkDataIsValid(mixed $data): bool { - $isInvalid = - !is_array($data) && - !$data instanceof stdClass; + $isInvalid = !is_array($data) && !$data instanceof stdClass; return !$isInvalid; } diff --git a/application/Espo/Core/Utils/File/ClassMap.php b/application/Espo/Core/Utils/File/ClassMap.php index 5545b313dd0..687cdea2e9d 100644 --- a/application/Espo/Core/Utils/File/ClassMap.php +++ b/application/Espo/Core/Utils/File/ClassMap.php @@ -29,6 +29,7 @@ namespace Espo\Core\Utils\File; +use Espo\Core\Binding\Attributes\Qualify; use Espo\Core\Utils\Config; use Espo\Core\Utils\DataCache; use Espo\Core\Utils\File\Manager as FileManager; @@ -43,6 +44,7 @@ class ClassMap public function __construct( private FileManager $fileManager, private Module $module, + #[Qualify(DataCache::QUALIFIER_SYSTEM)] private DataCache $dataCache, private PathProvider $pathProvider, private Config\SystemConfig $systemConfig, diff --git a/application/Espo/Core/Utils/Language.php b/application/Espo/Core/Utils/Language.php index 06154bf07a5..fe215bfa55f 100644 --- a/application/Espo/Core/Utils/Language.php +++ b/application/Espo/Core/Utils/Language.php @@ -29,6 +29,7 @@ namespace Espo\Core\Utils; +use Espo\Core\Binding\Attributes\Qualify; use Espo\Core\Utils\File\Manager as FileManager; use Espo\Core\Utils\Resource\Reader as ResourceReader; use Espo\Core\Utils\Resource\Reader\Params as ResourceReaderParams; @@ -80,6 +81,7 @@ public function __construct( ?string $language, private FileManager $fileManager, private ResourceReader $resourceReader, + #[Qualify(DataCache::QUALIFIER_SYSTEM)] private DataCache $dataCache, protected bool $useCache = false, protected bool $noCustom = false, diff --git a/application/Espo/Core/Utils/Metadata.php b/application/Espo/Core/Utils/Metadata.php index 96191c963f8..76c6365745c 100644 --- a/application/Espo/Core/Utils/Metadata.php +++ b/application/Espo/Core/Utils/Metadata.php @@ -29,6 +29,7 @@ namespace Espo\Core\Utils; +use Espo\Core\Binding\Attributes\Qualify; use Espo\Core\Utils\Cache\DataCacheAccess; use Espo\Core\Utils\File\Manager as FileManager; use Espo\Core\Utils\Metadata\Builder; @@ -61,7 +62,9 @@ public function __construct( private Module $module, private Builder $builder, private BuilderHelper $builderHelper, + #[Qualify(DataCache::QUALIFIER_SYSTEM)] private DataCacheAccess $data, + #[Qualify(DataCache::QUALIFIER_SYSTEM)] private DataCacheAccess $objectData, ) { diff --git a/application/Espo/Core/Utils/Metadata/OrmMetadataData.php b/application/Espo/Core/Utils/Metadata/OrmMetadataData.php index 64b2a7ce5e7..ada514fc089 100644 --- a/application/Espo/Core/Utils/Metadata/OrmMetadataData.php +++ b/application/Espo/Core/Utils/Metadata/OrmMetadataData.php @@ -29,6 +29,7 @@ namespace Espo\Core\Utils\Metadata; +use Espo\Core\Binding\Attributes\Qualify; use Espo\Core\InjectableFactory; use Espo\Core\Utils\Config; use Espo\Core\Utils\Database\Orm\Converter; @@ -44,6 +45,7 @@ class OrmMetadataData private ?Converter $converter = null; public function __construct( + #[Qualify(DataCache::QUALIFIER_SYSTEM)] private DataCache $dataCache, private InjectableFactory $injectableFactory, Config\SystemConfig $systemConfig, diff --git a/application/Espo/Core/Utils/Route.php b/application/Espo/Core/Utils/Route.php index 9cf4c0bb346..9d0d0a6262f 100644 --- a/application/Espo/Core/Utils/Route.php +++ b/application/Espo/Core/Utils/Route.php @@ -31,6 +31,7 @@ use Espo\Core\Api\Action; use Espo\Core\Api\Route as RouteItem; +use Espo\Core\Binding\Attributes\Qualify; use Espo\Core\Exceptions\BadRequest; use Espo\Core\Utils\Config\SystemConfig; use Espo\Core\Utils\File\Manager as FileManager; @@ -56,6 +57,7 @@ class Route public function __construct( private Metadata $metadata, private FileManager $fileManager, + #[Qualify(DataCache::QUALIFIER_SYSTEM)] private DataCache $dataCache, private PathProvider $pathProvider, private SystemConfig $systemConfig, diff --git a/application/Espo/Resources/metadata/app/containerServices.json b/application/Espo/Resources/metadata/app/containerServices.json index 1aac94eea09..592ac7ecd17 100644 --- a/application/Espo/Resources/metadata/app/containerServices.json +++ b/application/Espo/Resources/metadata/app/containerServices.json @@ -2,6 +2,9 @@ "authTokenManager": { "className": "Espo\\Core\\Authentication\\AuthToken\\EspoManager" }, + "dataCache": { + "loaderClassName": "Espo\\Core\\Utils\\Cache\\DataCacheServiceLoader" + }, "ormMetadataData": { "className": "Espo\\Core\\Utils\\Metadata\\OrmMetadataData" }, diff --git a/application/Espo/Tools/App/Language/AclDependencyProvider.php b/application/Espo/Tools/App/Language/AclDependencyProvider.php index 96fd6d2e270..09acdc5f94c 100644 --- a/application/Espo/Tools/App/Language/AclDependencyProvider.php +++ b/application/Espo/Tools/App/Language/AclDependencyProvider.php @@ -29,6 +29,7 @@ namespace Espo\Tools\App\Language; +use Espo\Core\Binding\Attributes\Qualify; use Espo\Core\ORM\Type\FieldType; use Espo\Core\Utils\Config\SystemConfig; use Espo\Core\Utils\DataCache; @@ -52,6 +53,7 @@ class AclDependencyProvider private bool $useCache; public function __construct( + #[Qualify(DataCache::QUALIFIER_SYSTEM)] private DataCache $dataCache, private Metadata $metadata, private Defs $ormDefs, diff --git a/application/Espo/Tools/App/Metadata/AclDependencyProvider.php b/application/Espo/Tools/App/Metadata/AclDependencyProvider.php index e9ba71cc3c9..635c213c058 100644 --- a/application/Espo/Tools/App/Metadata/AclDependencyProvider.php +++ b/application/Espo/Tools/App/Metadata/AclDependencyProvider.php @@ -29,6 +29,7 @@ namespace Espo\Tools\App\Metadata; +use Espo\Core\Binding\Attributes\Qualify; use Espo\Core\ORM\Type\FieldType; use Espo\Core\Utils\Config; use Espo\Core\Utils\DataCache; @@ -52,6 +53,7 @@ class AclDependencyProvider private bool $useCache; public function __construct( + #[Qualify(DataCache::QUALIFIER_SYSTEM)] private DataCache $dataCache, private Metadata $metadata, private Defs $ormDefs, diff --git a/application/Espo/Tools/LabelManager/LabelManager.php b/application/Espo/Tools/LabelManager/LabelManager.php index 0535c6cfc25..8e8992d3c04 100644 --- a/application/Espo/Tools/LabelManager/LabelManager.php +++ b/application/Espo/Tools/LabelManager/LabelManager.php @@ -42,13 +42,11 @@ class LabelManager implements Di\DefaultLanguageAware, Di\MetadataAware, - Di\FileManagerAware, - Di\DataCacheAware + Di\FileManagerAware { use Di\DefaultLanguageSetter; use Di\MetadataSetter; use Di\FileManagerSetter; - use Di\DataCacheSetter; /** @var string[] */ protected $ignoreList = [ diff --git a/tests/integration/Core/BaseTestCase.php b/tests/integration/Core/BaseTestCase.php index c1b971bea55..d861abb9869 100644 --- a/tests/integration/Core/BaseTestCase.php +++ b/tests/integration/Core/BaseTestCase.php @@ -37,6 +37,7 @@ use Espo\Core\DataManager; use Espo\Core\Exceptions\Error; use Espo\Core\InjectableFactory; +use Espo\Core\Utils\Cache\DataCacheServiceName as CacheServiceName; use Espo\Core\Utils\Config; use Espo\Core\Utils\File\Manager as FileManager; use Espo\Core\Utils\Metadata; @@ -249,6 +250,7 @@ protected function authenticate( 'module', 'fileManager', 'applicationParams', + CacheServiceName::SYSTEM, ]); if ($username === null && $method === null) { diff --git a/tests/integration/Espo/Core/Binding/BindingTest.php b/tests/integration/Espo/Core/Binding/BindingTest.php index 5bb2d80404f..820e4fc4d80 100644 --- a/tests/integration/Espo/Core/Binding/BindingTest.php +++ b/tests/integration/Espo/Core/Binding/BindingTest.php @@ -37,6 +37,8 @@ use Espo\Core\Binding\Key\QualifiedClassKey; use Espo\Core\Container\ContainerBuilder; use Espo\Core\InjectableFactory; +use Espo\Core\Utils\Cache\FileCacheItemPool; +use Psr\Cache\CacheItemPoolInterface; use tests\integration\Core\BaseTestCase; use tests\integration\testClasses\Binding\SomeClass; use tests\integration\testClasses\Binding\SomeClassRequiringService; @@ -57,6 +59,13 @@ public function load(): BindingData { $data = new BindingData(); + $binder = new Binder($data); + + $binder->bindImplementation( + CacheItemPoolInterface::class, + FileCacheItemPool::class + ); + $data->addGlobal( SomeInterface::class, Binding::createFromImplementationClassName( @@ -95,6 +104,13 @@ public function load(): BindingData { $data = new BindingData(); + $binder = new Binder($data); + + $binder->bindImplementation( + CacheItemPoolInterface::class, + FileCacheItemPool::class + ); + $data->addGlobal( SomeInterface::class, Binding::createFromFactoryClassName( @@ -133,6 +149,13 @@ public function load(): BindingData { $data = new BindingData(); + $binder = new Binder($data); + + $binder->bindImplementation( + CacheItemPoolInterface::class, + FileCacheItemPool::class + ); + $data->addGlobal( SomeInterface::class, Binding::createFromCallback( @@ -173,6 +196,13 @@ public function load(): BindingData { $data = new BindingData(); + $binder = new Binder($data); + + $binder->bindImplementation( + CacheItemPoolInterface::class, + FileCacheItemPool::class + ); + $data->addGlobal( SomeService::class, Binding::createFromServiceName('someService') @@ -214,6 +244,13 @@ public function load(): BindingData { $data = new BindingData(); + $binder = new Binder($data); + + $binder->bindImplementation( + CacheItemPoolInterface::class, + FileCacheItemPool::class + ); + $data->addContext( SomeClassRequiringValue::class, '$value', @@ -252,6 +289,11 @@ public function load(): BindingData $data = new BindingData(); $binder = new Binder($data); + $binder->bindImplementation( + CacheItemPoolInterface::class, + FileCacheItemPool::class + ); + $binder->bindService(SomeService::class, 'testService'); $binder->bindService(QualifiedClassKey::create(SomeService::class, 'alt'), 'testServiceAlt'); diff --git a/tests/unit/Espo/Core/Utils/DataCacheTest.php b/tests/unit/Espo/Core/Utils/DataCacheTest.php index b8b4cfb86cc..86443e52de8 100644 --- a/tests/unit/Espo/Core/Utils/DataCacheTest.php +++ b/tests/unit/Espo/Core/Utils/DataCacheTest.php @@ -29,6 +29,7 @@ namespace tests\unit\Espo\Core\Utils; +use Espo\Core\Utils\Cache\Exceptions\InvalidArgument; use Espo\Core\Utils\Cache\FileCacheItemPool; use Espo\Core\Utils\DataCache; use Espo\Core\Utils\File\Manager as FileManager; @@ -110,7 +111,7 @@ public function testStoreError() public function testStoreBadDataType() { - $this->expectException(InvalidArgumentException::class); + $this->expectException(InvalidArgument::class); $data = false; From 070d4a75e9baea732334de36d568921ed27bd4e4 Mon Sep 17 00:00:00 2001 From: Yurii Date: Tue, 21 Jul 2026 11:19:23 +0300 Subject: [PATCH 50/87] Update dependencies --- package-lock.json | 183 ++++++++++++++++++++++++++++++++-------------- 1 file changed, 129 insertions(+), 54 deletions(-) diff --git a/package-lock.json b/package-lock.json index eabc53efc09..93afc4a7de6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2851,9 +2851,9 @@ } }, "node_modules/archiver-utils/node_modules/brace-expansion": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", - "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", "dev": true, "license": "MIT", "dependencies": { @@ -3094,17 +3094,63 @@ "hasInstallScript": true }, "node_modules/axios": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz", - "integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==", + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz", + "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==", "dev": true, "license": "MIT", "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, + "node_modules/axios/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/axios/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/axios/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/axios/node_modules/proxy-from-env": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", @@ -3270,9 +3316,9 @@ } }, "node_modules/body-parser": { - "version": "1.20.5", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", - "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "version": "1.20.6", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", + "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", "dev": true, "license": "MIT", "dependencies": { @@ -3363,9 +3409,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", - "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", "dev": true, "license": "MIT", "dependencies": { @@ -4619,9 +4665,9 @@ } }, "node_modules/filelist/node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", "dev": true, "license": "MIT", "dependencies": { @@ -5075,9 +5121,9 @@ } }, "node_modules/glob/node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", "dev": true, "license": "MIT", "dependencies": { @@ -6107,9 +6153,9 @@ } }, "node_modules/jasmine-browser-runner/node_modules/brace-expansion": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", - "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", "dev": true, "license": "MIT", "dependencies": { @@ -7408,9 +7454,9 @@ } }, "node_modules/readdir-glob/node_modules/brace-expansion": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", - "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", "dev": true, "license": "MIT", "dependencies": { @@ -8170,9 +8216,9 @@ } }, "node_modules/tar": { - "version": "7.5.16", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.16.tgz", - "integrity": "sha512-56adEpPMouktRlBLXiaYFFzZ/3+JXa8P9n7WbR+ibIjtviN55mEaOkiysCnPnWm+7kkui1Dn8J9l+g6zV8731w==", + "version": "7.5.20", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.20.tgz", + "integrity": "sha512-9FcyK4PA6+WbzlTM9WhQm6vB5W7cP7dUiPsv1g7YDwEQnQ1CGpK3MGlKk/ITVWMk05kHZuBhmVhiv8LZoy/PFQ==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { @@ -10677,9 +10723,9 @@ }, "dependencies": { "brace-expansion": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", - "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", "dev": true, "requires": { "balanced-match": "^1.0.0" @@ -10813,16 +10859,45 @@ "integrity": "sha512-bYnD2oD9UMZBTxSWcvXH1MTcp0w+CUBfXe4HI1QJLGzJu+O27Ny5gqzRcFuDsZB9jrZ5SJjqAG0PieJsaUdTcg==" }, "axios": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz", - "integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==", + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz", + "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==", "dev": true, "requires": { "follow-redirects": "^1.16.0", "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" }, "dependencies": { + "agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, + "requires": { + "debug": "4" + } + }, + "debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "requires": { + "ms": "^2.1.3" + } + }, + "https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dev": true, + "requires": { + "agent-base": "6", + "debug": "4" + } + }, "proxy-from-env": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", @@ -10923,9 +10998,9 @@ "optional": true }, "body-parser": { - "version": "1.20.5", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", - "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "version": "1.20.6", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", + "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", "dev": true, "requires": { "bytes": "~3.1.2", @@ -10999,9 +11074,9 @@ } }, "brace-expansion": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", - "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", "dev": true, "requires": { "balanced-match": "^1.0.0", @@ -11884,9 +11959,9 @@ "dev": true }, "brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", "dev": true, "requires": { "balanced-match": "^4.0.2" @@ -12202,9 +12277,9 @@ "dev": true }, "brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", "dev": true, "requires": { "balanced-match": "^4.0.2" @@ -12951,9 +13026,9 @@ }, "dependencies": { "brace-expansion": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", - "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", "dev": true, "requires": { "balanced-match": "^1.0.0" @@ -13891,9 +13966,9 @@ }, "dependencies": { "brace-expansion": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", - "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", "dev": true, "requires": { "balanced-match": "^1.0.0" @@ -14454,9 +14529,9 @@ "dev": true }, "tar": { - "version": "7.5.16", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.16.tgz", - "integrity": "sha512-56adEpPMouktRlBLXiaYFFzZ/3+JXa8P9n7WbR+ibIjtviN55mEaOkiysCnPnWm+7kkui1Dn8J9l+g6zV8731w==", + "version": "7.5.20", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.20.tgz", + "integrity": "sha512-9FcyK4PA6+WbzlTM9WhQm6vB5W7cP7dUiPsv1g7YDwEQnQ1CGpK3MGlKk/ITVWMk05kHZuBhmVhiv8LZoy/PFQ==", "dev": true, "requires": { "@isaacs/fs-minipass": "^4.0.0", From 3f44f663f66659f115bd4851314f02f49423c4cf Mon Sep 17 00:00:00 2001 From: Yurii Date: Tue, 21 Jul 2026 12:09:51 +0300 Subject: [PATCH 51/87] Update guzzle --- composer.lock | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/composer.lock b/composer.lock index 0ab3cdb7e94..476d3c91a7c 100644 --- a/composer.lock +++ b/composer.lock @@ -1659,16 +1659,16 @@ }, { "name": "guzzlehttp/guzzle", - "version": "7.12.1", + "version": "7.12.2", "source": { "type": "git", "url": "https://github.com/guzzle/guzzle.git", - "reference": "d34627490fbc03bf5c5d7cfed81f2faa19519425" + "reference": "1289bf2975291b73e29e9422da675c87f56d28f1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/guzzle/zipball/d34627490fbc03bf5c5d7cfed81f2faa19519425", - "reference": "d34627490fbc03bf5c5d7cfed81f2faa19519425", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/1289bf2975291b73e29e9422da675c87f56d28f1", + "reference": "1289bf2975291b73e29e9422da675c87f56d28f1", "shasum": "" }, "require": { @@ -1678,7 +1678,7 @@ "php": "^7.2.5 || ^8.0", "psr/http-client": "^1.0", "symfony/deprecation-contracts": "^2.5 || ^3.0", - "symfony/polyfill-php80": "^1.24" + "symfony/polyfill-php80": "^1.25" }, "provide": { "psr/http-client-implementation": "1.0" @@ -1767,7 +1767,7 @@ ], "support": { "issues": "https://github.com/guzzle/guzzle/issues", - "source": "https://github.com/guzzle/guzzle/tree/7.12.1" + "source": "https://github.com/guzzle/guzzle/tree/7.12.2" }, "funding": [ { @@ -1783,7 +1783,7 @@ "type": "tidelift" } ], - "time": "2026-06-18T14:12:49+00:00" + "time": "2026-06-23T13:08:19+00:00" }, { "name": "guzzlehttp/promises", From 37bcde3a4434ebab19465c6565ad16e715a5dd6e Mon Sep 17 00:00:00 2001 From: Yurii Date: Tue, 21 Jul 2026 12:19:59 +0300 Subject: [PATCH 52/87] Update composer deps --- composer.json | 4 ++-- composer.lock | 60 +++++++++++++++++++++++++-------------------------- 2 files changed, 32 insertions(+), 32 deletions(-) diff --git a/composer.json b/composer.json index 4b059737b00..4e7b0f0db45 100644 --- a/composer.json +++ b/composer.json @@ -32,7 +32,7 @@ "symfony/routing": "^7", "cboden/ratchet": "0.4.x-dev#9ac6a412a26d8bae6541ee0b65ffaf78e316b1a6", "react/zmq": "^0.4.0", - "guzzlehttp/psr7": "^2.9.0", + "guzzlehttp/psr7": "^2.13", "michelf/php-markdown": "^1.9", "robthree/twofactorauth": "^1.8", "nesbot/carbon": "^3.11.0", @@ -55,7 +55,7 @@ "lasserafn/php-initial-avatar-generator": "dev-update-image-lib#a46ab8f1427f93c5b37957e739205da7fcca0290", "directorytree/imapengine": "^1.19", "zbateson/mail-mime-parser": "^3.0", - "guzzlehttp/guzzle": "^7.10", + "guzzlehttp/guzzle": "^7.15", "devtheorem/php-handlebars": "^1.0", "php-amqplib/php-amqplib": "^3.7", "predis/predis": "^3.5", diff --git a/composer.lock b/composer.lock index 476d3c91a7c..7961ab2d395 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "038edf6480a482ceac6acb6a643eee35", + "content-hash": "34ac75e2293db87de4f484db7ff3c921", "packages": [ { "name": "async-aws/core", @@ -1659,22 +1659,22 @@ }, { "name": "guzzlehttp/guzzle", - "version": "7.12.2", + "version": "7.15.1", "source": { "type": "git", "url": "https://github.com/guzzle/guzzle.git", - "reference": "1289bf2975291b73e29e9422da675c87f56d28f1" + "reference": "61443dfb33c62f308ee8add20f45b4d6e4bf8d2f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/guzzle/zipball/1289bf2975291b73e29e9422da675c87f56d28f1", - "reference": "1289bf2975291b73e29e9422da675c87f56d28f1", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/61443dfb33c62f308ee8add20f45b4d6e4bf8d2f", + "reference": "61443dfb33c62f308ee8add20f45b4d6e4bf8d2f", "shasum": "" }, "require": { "ext-json": "*", - "guzzlehttp/promises": "^2.5", - "guzzlehttp/psr7": "^2.12.1", + "guzzlehttp/promises": "^2.5.1", + "guzzlehttp/psr7": "^2.13", "php": "^7.2.5 || ^8.0", "psr/http-client": "^1.0", "symfony/deprecation-contracts": "^2.5 || ^3.0", @@ -1686,8 +1686,8 @@ "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", "ext-curl": "*", - "guzzle/client-integration-tests": "3.0.2", - "guzzlehttp/test-server": "^0.5.1", + "guzzle/client-integration-tests": "3.0.3", + "guzzlehttp/test-server": "^0.7", "php-http/message-factory": "^1.1", "phpunit/phpunit": "^8.5.52 || ^9.6.34", "psr/log": "^1.1 || ^2.0 || ^3.0" @@ -1767,7 +1767,7 @@ ], "support": { "issues": "https://github.com/guzzle/guzzle/issues", - "source": "https://github.com/guzzle/guzzle/tree/7.12.2" + "source": "https://github.com/guzzle/guzzle/tree/7.15.1" }, "funding": [ { @@ -1783,20 +1783,20 @@ "type": "tidelift" } ], - "time": "2026-06-23T13:08:19+00:00" + "time": "2026-07-18T11:23:11+00:00" }, { "name": "guzzlehttp/promises", - "version": "2.5.0", + "version": "2.5.1", "source": { "type": "git", "url": "https://github.com/guzzle/promises.git", - "reference": "4360e982f87f5f258bf872d094647791db2f4c8e" + "reference": "9ad1e4fc607446a055b95870c7f668e93b5cff29" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/promises/zipball/4360e982f87f5f258bf872d094647791db2f4c8e", - "reference": "4360e982f87f5f258bf872d094647791db2f4c8e", + "url": "https://api.github.com/repos/guzzle/promises/zipball/9ad1e4fc607446a055b95870c7f668e93b5cff29", + "reference": "9ad1e4fc607446a055b95870c7f668e93b5cff29", "shasum": "" }, "require": { @@ -1851,7 +1851,7 @@ ], "support": { "issues": "https://github.com/guzzle/promises/issues", - "source": "https://github.com/guzzle/promises/tree/2.5.0" + "source": "https://github.com/guzzle/promises/tree/2.5.1" }, "funding": [ { @@ -1867,20 +1867,20 @@ "type": "tidelift" } ], - "time": "2026-06-02T12:23:43+00:00" + "time": "2026-07-08T15:48:39+00:00" }, { "name": "guzzlehttp/psr7", - "version": "2.12.1", + "version": "2.13.0", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "172ef2f4e9824c1e058b7f30be8ae25a02c0f2b7" + "reference": "dad89620b7a6edb60c15858442eb2e408b45d8f4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/172ef2f4e9824c1e058b7f30be8ae25a02c0f2b7", - "reference": "172ef2f4e9824c1e058b7f30be8ae25a02c0f2b7", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/dad89620b7a6edb60c15858442eb2e408b45d8f4", + "reference": "dad89620b7a6edb60c15858442eb2e408b45d8f4", "shasum": "" }, "require": { @@ -1889,7 +1889,7 @@ "psr/http-message": "^1.1 || ^2.0", "ralouphie/getallheaders": "^3.0", "symfony/deprecation-contracts": "^2.5 || ^3.0", - "symfony/polyfill-php80": "^1.24" + "symfony/polyfill-php80": "^1.25" }, "provide": { "psr/http-factory-implementation": "1.0", @@ -1970,7 +1970,7 @@ ], "support": { "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/2.12.1" + "source": "https://github.com/guzzle/psr7/tree/2.13.0" }, "funding": [ { @@ -1986,7 +1986,7 @@ "type": "tidelift" } ], - "time": "2026-06-18T09:49:37+00:00" + "time": "2026-07-16T22:23:49+00:00" }, { "name": "illuminate/collections", @@ -6291,16 +6291,16 @@ }, { "name": "symfony/deprecation-contracts", - "version": "v3.7.0", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b" + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b", - "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d", "shasum": "" }, "require": { @@ -6338,7 +6338,7 @@ "description": "A generic function and convention to trigger deprecation notices", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.0" + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1" }, "funding": [ { @@ -6358,7 +6358,7 @@ "type": "tidelift" } ], - "time": "2026-04-13T15:52:40+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { "name": "symfony/event-dispatcher", From 4ccde30c62a9f2312f6cfb65226f05c1c74c072f Mon Sep 17 00:00:00 2001 From: Yurii Date: Tue, 21 Jul 2026 14:03:35 +0300 Subject: [PATCH 53/87] Clear cache in Data Cache --- application/Espo/Core/DataManager.php | 23 ++++++++++++++++++++++- application/Espo/Core/Utils/DataCache.php | 15 +++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/application/Espo/Core/DataManager.php b/application/Espo/Core/DataManager.php index 2361ead3ff5..0a52156f4e0 100644 --- a/application/Espo/Core/DataManager.php +++ b/application/Espo/Core/DataManager.php @@ -29,12 +29,15 @@ namespace Espo\Core; +use Espo\Core\Binding\Attributes\Qualify; use Espo\Core\Exceptions\Error; use Espo\Core\Hook\Control; use Espo\Core\ORM\EntityManagerProxy; +use Espo\Core\Utils\Cache\Exceptions\PersistenceError; use Espo\Core\Utils\Database\Helper as DatabaseHelper; use Espo\Core\Utils\Database\Schema\RebuildMode; use Espo\Core\Utils\Database\Schema\SchemaManagerProxy; +use Espo\Core\Utils\DataCache; use Espo\Core\Utils\File\Manager as FileManager; use Espo\Core\Utils\Metadata; use Espo\Core\Utils\Util; @@ -71,6 +74,9 @@ public function __construct( private DatabaseParamsFactory $databaseParamsFactory, private InjectableFactory $injectableFactory, private Control $hookControl, + #[Qualify(DataCache::QUALIFIER_SYSTEM)] + private DataCache $systemDataCache, + private DataCache $dataCache, ) {} /** @@ -96,15 +102,30 @@ public function rebuild(?array $entityTypeList = null): void * Clear cache. * * @throws Error + * @todo Trigger an inter-process event. */ public function clearCache(): void { $this->module->clearCache(); + try { + $this->systemDataCache->clearAll(); + } catch (PersistenceError $e) { + throw new Error("Could not clear system cache.", previous: $e); + } + + if ($this->systemDataCache !== $this->dataCache) { + try { + $this->dataCache->clearAll(); + } catch (PersistenceError $e) { + throw new Error("Could not clear application cache.", previous: $e); + } + } + $result = $this->fileManager->removeInDir($this->cachePath); if (!$result) { - throw new Error("Error while clearing cache"); + throw new Error("Error while clearing cache."); } $this->updateCacheTimestamp(); diff --git a/application/Espo/Core/Utils/DataCache.php b/application/Espo/Core/Utils/DataCache.php index 7cd309ec626..ba308b0580f 100644 --- a/application/Espo/Core/Utils/DataCache.php +++ b/application/Espo/Core/Utils/DataCache.php @@ -145,6 +145,21 @@ public function clear(string $key): void $this->pool->deleteItem($key); } + /** + * Clears all cache. + * + * @since 10.1.0 + * @throws PersistenceError + */ + public function clearAll(): void + { + $result = $this->pool->clear(); + + if ($result === false) { + throw new PersistenceError("Could not clear cache."); + } + } + private function checkDataIsValid(mixed $data): bool { $isInvalid = !is_array($data) && !$data instanceof stdClass; From 28cfbac829b0556963cba191d89d9d219af384ef Mon Sep 17 00:00:00 2001 From: Yurii Date: Tue, 21 Jul 2026 14:04:05 +0300 Subject: [PATCH 54/87] Fix comment --- application/Espo/Core/Utils/Module.php | 1 - 1 file changed, 1 deletion(-) diff --git a/application/Espo/Core/Utils/Module.php b/application/Espo/Core/Utils/Module.php index 8f36200fe45..2e823a6d2a1 100644 --- a/application/Espo/Core/Utils/Module.php +++ b/application/Espo/Core/Utils/Module.php @@ -175,7 +175,6 @@ public function getList(): array /** * @todo Use event-dispatcher class (passed via constructor). - * `$this->clearCacheEventDispatcher->subscribe(...);` */ public function clearCache(): void { From 585afda362d176e71e0a2d1e49f0df544c1e6c5d Mon Sep 17 00:00:00 2001 From: Yurii Date: Wed, 22 Jul 2026 15:29:51 +0300 Subject: [PATCH 55/87] Default button text style --- client/src/views/record/search.ts | 13 ++++++++++-- frontend/less/espo/elements/buttons.less | 25 ++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/client/src/views/record/search.ts b/client/src/views/record/search.ts index 20d017b5198..da8158213ce 100644 --- a/client/src/views/record/search.ts +++ b/client/src/views/record/search.ts @@ -1033,9 +1033,18 @@ class SearchView extends View { this.currentFilterLabelList!.push(filterLabel); this.filtersButton.classList - .remove('btn-default', 'btn-primary', 'btn-danger', 'btn-success', 'btn-info', 'btn-info'); + .remove(...[ + 'text-primary', + 'text-danger', + 'text-success', + 'text-info', + 'text-warning', + ]); - this.filtersButton.classList.add(`btn-${filterStyle}`); + + if (filterStyle !== 'default') { + this.filtersButton.classList.add(`text-${filterStyle}`); + } presetName = presetName || ''; diff --git a/frontend/less/espo/elements/buttons.less b/frontend/less/espo/elements/buttons.less index 43f56a89c8a..46bc21f168e 100644 --- a/frontend/less/espo/elements/buttons.less +++ b/frontend/less/espo/elements/buttons.less @@ -519,3 +519,28 @@ a.btn { var(--btn-info-active-border) ); } + +.btn { + &.btn-default, + &.btn-text { + &.text-success { + color: var(--state-success-text); + } + + &.text-danger { + color: var(--state-danger-text); + } + + &.text-warning { + color: var(--state-warning-text); + } + + &.text-primary { + color: var(--state-primary-text); + } + + &.text-info { + color: var(--state-info-text); + } + } +} From 1de121f00b8f8a07eed83635558c90bde44da568 Mon Sep 17 00:00:00 2001 From: Yurii Date: Fri, 24 Jul 2026 10:25:05 +0300 Subject: [PATCH 56/87] Data cache provider --- application/Espo/Core/DataManager.php | 12 +++-- .../Core/Utils/Cache/DataCacheProvider.php | 52 +++++++++++++++++++ 2 files changed, 60 insertions(+), 4 deletions(-) create mode 100644 application/Espo/Core/Utils/Cache/DataCacheProvider.php diff --git a/application/Espo/Core/DataManager.php b/application/Espo/Core/DataManager.php index 0a52156f4e0..fd8d3f2eacb 100644 --- a/application/Espo/Core/DataManager.php +++ b/application/Espo/Core/DataManager.php @@ -33,6 +33,7 @@ use Espo\Core\Exceptions\Error; use Espo\Core\Hook\Control; use Espo\Core\ORM\EntityManagerProxy; +use Espo\Core\Utils\Cache\DataCacheProvider; use Espo\Core\Utils\Cache\Exceptions\PersistenceError; use Espo\Core\Utils\Database\Helper as DatabaseHelper; use Espo\Core\Utils\Database\Schema\RebuildMode; @@ -76,7 +77,7 @@ public function __construct( private Control $hookControl, #[Qualify(DataCache::QUALIFIER_SYSTEM)] private DataCache $systemDataCache, - private DataCache $dataCache, + private DataCacheProvider $dataCacheProvider, ) {} /** @@ -108,21 +109,24 @@ public function clearCache(): void { $this->module->clearCache(); + $result = $this->fileManager->removeInDir($this->cachePath); + try { $this->systemDataCache->clearAll(); } catch (PersistenceError $e) { throw new Error("Could not clear system cache.", previous: $e); } - if ($this->systemDataCache !== $this->dataCache) { + $dataCache = $this->dataCacheProvider->get(); + + if ($this->systemDataCache !== $dataCache) { try { - $this->dataCache->clearAll(); + $dataCache->clearAll(); } catch (PersistenceError $e) { throw new Error("Could not clear application cache.", previous: $e); } } - $result = $this->fileManager->removeInDir($this->cachePath); if (!$result) { throw new Error("Error while clearing cache."); diff --git a/application/Espo/Core/Utils/Cache/DataCacheProvider.php b/application/Espo/Core/Utils/Cache/DataCacheProvider.php new file mode 100644 index 00000000000..4037e197fd3 --- /dev/null +++ b/application/Espo/Core/Utils/Cache/DataCacheProvider.php @@ -0,0 +1,52 @@ +. + * + * 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\Cache; + +use Espo\Core\Container; +use Espo\Core\Utils\DataCache; + +/** + * May be needed when data cache is corrupted not allowing to load the DataCache service + * when clearing cache. + * + * @internal + * @since 10.1.0 + */ +class DataCacheProvider +{ + public function __construct( + private Container $container, + ) {} + + public function get(): DataCache + { + return $this->container->getByClass(DataCache::class); + } +} From 6fa2eeb50df94a302c839809df6e3c7a6ba4676d Mon Sep 17 00:00:00 2001 From: Yurii Date: Fri, 24 Jul 2026 10:25:19 +0300 Subject: [PATCH 57/87] Cleanup --- application/Espo/Core/DataManager.php | 1 - 1 file changed, 1 deletion(-) diff --git a/application/Espo/Core/DataManager.php b/application/Espo/Core/DataManager.php index fd8d3f2eacb..4b30a9df76d 100644 --- a/application/Espo/Core/DataManager.php +++ b/application/Espo/Core/DataManager.php @@ -127,7 +127,6 @@ public function clearCache(): void } } - if (!$result) { throw new Error("Error while clearing cache."); } From 67e24911b5b1d315eb82e720afd9ab8128fb93ac Mon Sep 17 00:00:00 2001 From: Yurii Date: Fri, 24 Jul 2026 10:54:10 +0300 Subject: [PATCH 58/87] Clear cache try cache --- application/Espo/Core/DataManager.php | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/application/Espo/Core/DataManager.php b/application/Espo/Core/DataManager.php index 4b30a9df76d..a409586a799 100644 --- a/application/Espo/Core/DataManager.php +++ b/application/Espo/Core/DataManager.php @@ -117,9 +117,15 @@ public function clearCache(): void throw new Error("Could not clear system cache.", previous: $e); } - $dataCache = $this->dataCacheProvider->get(); + try { + $dataCache = $this->dataCacheProvider->get(); + } catch (Throwable $e) { + $this->log->error("Could not get application data cache.", ['exception' => $e]); + + $dataCache = null; + } - if ($this->systemDataCache !== $dataCache) { + if ($dataCache && $this->systemDataCache !== $dataCache) { try { $dataCache->clearAll(); } catch (PersistenceError $e) { From 5a81845cb647321312ec3c97e18af84af9466e33 Mon Sep 17 00:00:00 2001 From: Eymen Elkum Date: Fri, 24 Jul 2026 11:51:21 +0300 Subject: [PATCH 59/87] Add theme direction parameter and improve RTL support (#3731) * Add theme direction parameter * Apply theme direction to client pages * Add direction to theme settings * Derive lead capture direction from language * Set WYSIWYG iframe direction * Add logical pull utility classes * Use logical properties in shared styles * Replace EspoRtl with shared RTL styles * Add RTL support to calendars * Migrate EspoRtl theme settings * Add theme direction tests * Use enum for theme direction * Preserve default theme preference behavior --- .../Migrations/V10_1/AfterUpgrade.php | 147 ++++++ .../Espo/Core/Utils/Client/ActionRenderer.php | 4 + .../Utils/Client/ActionRenderer/Params.php | 15 + .../Espo/Core/Utils/Client/RenderParams.php | 4 + application/Espo/Core/Utils/ClientManager.php | 6 + .../Espo/Core/Utils/Theme/Direction.php | 36 ++ application/Espo/Core/Utils/ThemeManager.php | 27 ++ .../Espo/EntryPoints/LeadCaptureForm.php | 3 +- .../Espo/Resources/defaults/config.php | 5 +- .../Espo/Resources/i18n/en_US/Global.json | 5 +- .../Espo/Resources/i18n/ur_IN/Global.json | 3 +- .../Espo/Resources/metadata/themes/Espo.json | 8 + .../Resources/metadata/themes/EspoRtl.json | 15 - .../Espo/Tools/LeadCapture/FormService.php | 10 + .../crm/src/views/calendar/calendar.js | 1 + .../crm/src/views/calendar/timeline.js | 1 + client/res/templates/record/detail.tpl | 4 +- client/res/templates/record/edit.tpl | 2 +- client/src/app.js | 13 + client/src/theme-manager.js | 11 + client/src/views/admin/user-interface.js | 6 +- client/src/views/fields/wysiwyg.ts | 3 +- .../views/lead-capture/fields/form-theme.js | 11 +- client/src/views/preferences/fields/theme.js | 22 +- client/src/views/preferences/record/edit.js | 1 + client/src/views/settings/fields/theme.js | 91 +++- client/src/views/site/master.js | 1 + .../espo-rtl/bootstrap-rtl/alerts-rtl.less | 18 - .../espo-rtl/bootstrap-rtl/badges-rtl.less | 21 - .../bootstrap-rtl/bootstrap-flipped.less | 64 --- .../espo-rtl/bootstrap-rtl/bootstrap-rtl.less | 49 -- .../bootstrap-rtl/button-groups-rtl.less | 94 ---- .../espo-rtl/bootstrap-rtl/carousel-rtl.less | 76 --- .../espo-rtl/bootstrap-rtl/close-rtl.less | 8 - .../espo-rtl/bootstrap-rtl/dropdowns-rtl.less | 63 --- .../less/espo-rtl/bootstrap-rtl/flipped.less | 83 ---- .../espo-rtl/bootstrap-rtl/forms-rtl.less | 102 ---- .../less/espo-rtl/bootstrap-rtl/grid-rtl.less | 45 -- .../bootstrap-rtl/input-groups-rtl.less | 75 --- .../bootstrap-rtl/list-group-rtl.less | 14 - .../espo-rtl/bootstrap-rtl/media-rtl.less | 47 -- .../bootstrap-rtl/mixins-flipped.less | 31 -- .../espo-rtl/bootstrap-rtl/mixins-rtl.less | 31 -- .../mixins/border-radius-flipped.less | 10 - .../bootstrap-rtl/mixins/gradients-rtl.less | 24 - .../mixins/grid-framework-rtl.less | 96 ---- .../bootstrap-rtl/mixins/grid-rtl.less | 98 ---- .../espo-rtl/bootstrap-rtl/modals-rtl.less | 27 -- .../espo-rtl/bootstrap-rtl/navbar-rtl.less | 83 ---- .../less/espo-rtl/bootstrap-rtl/navs-rtl.less | 86 ---- .../espo-rtl/bootstrap-rtl/normalize-rtl.less | 15 - .../espo-rtl/bootstrap-rtl/panels-rtl.less | 70 --- .../espo-rtl/bootstrap-rtl/popovers-rtl.less | 27 -- .../bootstrap-rtl/progress-bars-rtl.less | 8 - .../bootstrap-rtl/responsive-embed-rtl.less | 15 - .../espo-rtl/bootstrap-rtl/tables-rtl.less | 47 -- .../less/espo-rtl/bootstrap-rtl/type-rtl.less | 45 -- .../espo-rtl/bootstrap-rtl/utilities-rtl.less | 15 - .../espo-rtl/bootstrap-rtl/variables-rtl.less | 8 - frontend/less/espo-rtl/iframe/main.less | 7 - frontend/less/espo-rtl/main.less | 19 - frontend/less/espo-rtl/variables.less | 3 - frontend/less/espo/bootstrap/utilities.less | 6 + frontend/less/espo/custom.less | 12 +- frontend/less/espo/main.less | 1 + frontend/less/espo/misc/kanban.less | 2 +- frontend/less/espo/rtl/bootstrap.less | 444 ++++++++++++++++++ .../less/{espo-rtl => espo/rtl}/custom.less | 280 ++++++----- .../{espo-rtl => espo/rtl}/layout-side.less | 0 .../{espo-rtl => espo/rtl}/layout-top.less | 5 - frontend/less/espo/rtl/main.less | 6 + frontend/test/spec/test.theme-manager.js | 50 ++ html/main.html | 4 +- .../Migrations/V10_1/AfterUpgradeTest.php | 48 ++ .../unit/Espo/Core/Utils/ThemeManagerTest.php | 82 ++++ .../Tools/LeadCapture/FormServiceTest.php | 41 ++ 76 files changed, 1243 insertions(+), 1717 deletions(-) create mode 100644 application/Espo/Core/Upgrades/Migrations/V10_1/AfterUpgrade.php create mode 100644 application/Espo/Core/Utils/Theme/Direction.php delete mode 100644 application/Espo/Resources/metadata/themes/EspoRtl.json delete mode 100644 frontend/less/espo-rtl/bootstrap-rtl/alerts-rtl.less delete mode 100644 frontend/less/espo-rtl/bootstrap-rtl/badges-rtl.less delete mode 100644 frontend/less/espo-rtl/bootstrap-rtl/bootstrap-flipped.less delete mode 100644 frontend/less/espo-rtl/bootstrap-rtl/bootstrap-rtl.less delete mode 100644 frontend/less/espo-rtl/bootstrap-rtl/button-groups-rtl.less delete mode 100644 frontend/less/espo-rtl/bootstrap-rtl/carousel-rtl.less delete mode 100644 frontend/less/espo-rtl/bootstrap-rtl/close-rtl.less delete mode 100644 frontend/less/espo-rtl/bootstrap-rtl/dropdowns-rtl.less delete mode 100644 frontend/less/espo-rtl/bootstrap-rtl/flipped.less delete mode 100644 frontend/less/espo-rtl/bootstrap-rtl/forms-rtl.less delete mode 100644 frontend/less/espo-rtl/bootstrap-rtl/grid-rtl.less delete mode 100644 frontend/less/espo-rtl/bootstrap-rtl/input-groups-rtl.less delete mode 100644 frontend/less/espo-rtl/bootstrap-rtl/list-group-rtl.less delete mode 100644 frontend/less/espo-rtl/bootstrap-rtl/media-rtl.less delete mode 100644 frontend/less/espo-rtl/bootstrap-rtl/mixins-flipped.less delete mode 100644 frontend/less/espo-rtl/bootstrap-rtl/mixins-rtl.less delete mode 100644 frontend/less/espo-rtl/bootstrap-rtl/mixins/border-radius-flipped.less delete mode 100644 frontend/less/espo-rtl/bootstrap-rtl/mixins/gradients-rtl.less delete mode 100644 frontend/less/espo-rtl/bootstrap-rtl/mixins/grid-framework-rtl.less delete mode 100644 frontend/less/espo-rtl/bootstrap-rtl/mixins/grid-rtl.less delete mode 100644 frontend/less/espo-rtl/bootstrap-rtl/modals-rtl.less delete mode 100644 frontend/less/espo-rtl/bootstrap-rtl/navbar-rtl.less delete mode 100644 frontend/less/espo-rtl/bootstrap-rtl/navs-rtl.less delete mode 100644 frontend/less/espo-rtl/bootstrap-rtl/normalize-rtl.less delete mode 100644 frontend/less/espo-rtl/bootstrap-rtl/panels-rtl.less delete mode 100644 frontend/less/espo-rtl/bootstrap-rtl/popovers-rtl.less delete mode 100644 frontend/less/espo-rtl/bootstrap-rtl/progress-bars-rtl.less delete mode 100644 frontend/less/espo-rtl/bootstrap-rtl/responsive-embed-rtl.less delete mode 100644 frontend/less/espo-rtl/bootstrap-rtl/tables-rtl.less delete mode 100644 frontend/less/espo-rtl/bootstrap-rtl/type-rtl.less delete mode 100644 frontend/less/espo-rtl/bootstrap-rtl/utilities-rtl.less delete mode 100644 frontend/less/espo-rtl/bootstrap-rtl/variables-rtl.less delete mode 100644 frontend/less/espo-rtl/iframe/main.less delete mode 100644 frontend/less/espo-rtl/main.less delete mode 100644 frontend/less/espo-rtl/variables.less create mode 100644 frontend/less/espo/rtl/bootstrap.less rename frontend/less/{espo-rtl => espo/rtl}/custom.less (69%) rename frontend/less/{espo-rtl => espo/rtl}/layout-side.less (100%) rename frontend/less/{espo-rtl => espo/rtl}/layout-top.less (92%) create mode 100644 frontend/less/espo/rtl/main.less create mode 100644 tests/unit/Espo/Core/Upgrades/Migrations/V10_1/AfterUpgradeTest.php create mode 100644 tests/unit/Espo/Core/Utils/ThemeManagerTest.php create mode 100644 tests/unit/Espo/Tools/LeadCapture/FormServiceTest.php diff --git a/application/Espo/Core/Upgrades/Migrations/V10_1/AfterUpgrade.php b/application/Espo/Core/Upgrades/Migrations/V10_1/AfterUpgrade.php new file mode 100644 index 00000000000..5c889f027e1 --- /dev/null +++ b/application/Espo/Core/Upgrades/Migrations/V10_1/AfterUpgrade.php @@ -0,0 +1,147 @@ +. + * + * 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\Upgrades\Migrations\V10_1; + +use Espo\Core\Upgrades\Migration\Script; +use Espo\Core\Utils\Config; +use Espo\Core\Utils\Config\ConfigWriter; +use Espo\Core\Utils\Theme\Direction; +use Espo\Entities\LeadCapture; +use Espo\Entities\Portal; +use Espo\Entities\Preferences; +use Espo\Entities\User; +use Espo\ORM\Entity; +use Espo\ORM\EntityManager; + +class AfterUpgrade implements Script +{ + private const LEGACY_THEME = 'EspoRtl'; + private const THEME = 'Espo'; + + public function __construct( + private EntityManager $entityManager, + private Config $config, + private ConfigWriter $configWriter, + ) {} + + public function run(): void + { + $this->updateConfig(); + $this->updatePreferences(); + $this->updatePortals(); + $this->updateLeadCaptures(); + } + + private function updateConfig(): void + { + if ($this->config->get('theme') !== self::LEGACY_THEME) { + return; + } + + $this->configWriter->set('theme', self::THEME); + $this->configWriter->set('themeParams', $this->prepareThemeParams($this->config->get('themeParams'))); + $this->configWriter->save(); + } + + private function updatePreferences(): void + { + $users = $this->entityManager + ->getRDBRepositoryByClass(User::class) + ->where([ + User::ATTR_TYPE => [ + User::TYPE_ADMIN, + User::TYPE_REGULAR, + User::TYPE_PORTAL, + ], + ]) + ->find(); + + foreach ($users as $user) { + $preferences = $this->entityManager + ->getRepositoryByClass(Preferences::class) + ->getById($user->getId()); + + if (!$preferences || $preferences->get('theme') !== self::LEGACY_THEME) { + continue; + } + + $this->updateThemeEntity($preferences); + } + } + + private function updatePortals(): void + { + $portals = $this->entityManager + ->getRDBRepositoryByClass(Portal::class) + ->where(['theme' => self::LEGACY_THEME]) + ->find(); + + foreach ($portals as $portal) { + $this->updateThemeEntity($portal); + } + } + + private function updateLeadCaptures(): void + { + $leadCaptures = $this->entityManager + ->getRDBRepositoryByClass(LeadCapture::class) + ->where(['formTheme' => self::LEGACY_THEME]) + ->find(); + + foreach ($leadCaptures as $leadCapture) { + $leadCapture->set('formTheme', self::THEME); + $this->entityManager->saveEntity($leadCapture); + } + } + + private function updateThemeEntity(Entity $entity): void + { + $entity->set('theme', self::THEME); + $entity->set('themeParams', $this->prepareThemeParams($entity->get('themeParams'))); + + $this->entityManager->saveEntity($entity); + } + + private function prepareThemeParams(mixed $themeParams): object + { + if (is_object($themeParams)) { + $themeParams = get_object_vars($themeParams); + } + + if (!is_array($themeParams)) { + $themeParams = []; + } + + $themeParams['navbar'] ??= 'top'; + $themeParams['direction'] = Direction::Rtl->value; + + return (object) $themeParams; + } +} diff --git a/application/Espo/Core/Utils/Client/ActionRenderer.php b/application/Espo/Core/Utils/Client/ActionRenderer.php index 8e5ab08bac9..075e768f94a 100644 --- a/application/Espo/Core/Utils/Client/ActionRenderer.php +++ b/application/Espo/Core/Utils/Client/ActionRenderer.php @@ -33,6 +33,7 @@ use Espo\Core\Utils\Client\ActionRenderer\Params; use Espo\Core\Utils\Json; use Espo\Core\Utils\ClientManager; +use Espo\Core\Utils\Theme\Direction; /** * Renders a front-end page that executes a controller action. Utilized by entry points. @@ -56,6 +57,7 @@ public function write(Response $response, Params $params): void scripts: $params->getScripts(), pageTitle: $params->getPageTitle(), theme: $params->getTheme(), + direction: $params->getDirection(), ); $securityParams = new SecurityParams( @@ -78,6 +80,7 @@ private function render( array $scripts, ?string $pageTitle, ?string $theme, + ?Direction $direction, ): string { $encodedData = Json::encode($data); @@ -99,6 +102,7 @@ private function render( scripts: $scripts, pageTitle: $pageTitle, theme: $theme, + direction: $direction, ); return $this->clientManager->render($params); diff --git a/application/Espo/Core/Utils/Client/ActionRenderer/Params.php b/application/Espo/Core/Utils/Client/ActionRenderer/Params.php index bca03a896e6..971b48a48ae 100644 --- a/application/Espo/Core/Utils/Client/ActionRenderer/Params.php +++ b/application/Espo/Core/Utils/Client/ActionRenderer/Params.php @@ -30,6 +30,7 @@ namespace Espo\Core\Utils\Client\ActionRenderer; use Espo\Core\Utils\Client\Script; +use Espo\Core\Utils\Theme\Direction; /** * Immutable. @@ -45,6 +46,7 @@ class Params private array $scripts = []; private ?string $pageTitle = null; private ?string $theme = null; + private ?Direction $direction = null; /** * @param ?array $data @@ -130,6 +132,14 @@ public function withTheme(?string $theme): self return $obj; } + public function withDirection(?Direction $direction): self + { + $obj = clone $this; + $obj->direction = $direction; + + return $obj; + } + public function getController(): string { return $this->controller; @@ -186,4 +196,9 @@ public function getTheme(): ?string { return $this->theme; } + + public function getDirection(): ?Direction + { + return $this->direction; + } } diff --git a/application/Espo/Core/Utils/Client/RenderParams.php b/application/Espo/Core/Utils/Client/RenderParams.php index 974401e70b1..c21ad515be7 100644 --- a/application/Espo/Core/Utils/Client/RenderParams.php +++ b/application/Espo/Core/Utils/Client/RenderParams.php @@ -29,6 +29,8 @@ namespace Espo\Core\Utils\Client; +use Espo\Core\Utils\Theme\Direction; + readonly class RenderParams { /** @@ -36,11 +38,13 @@ * @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. + * @param ?Direction $direction A document direction. */ public function __construct( public ?string $runScript = null, public array $scripts = [], public ?string $pageTitle = null, public ?string $theme = null, + public ?Direction $direction = null, ) {} } diff --git a/application/Espo/Core/Utils/ClientManager.php b/application/Espo/Core/Utils/ClientManager.php index c52a53a5e3d..b245a0527d7 100644 --- a/application/Espo/Core/Utils/ClientManager.php +++ b/application/Espo/Core/Utils/ClientManager.php @@ -41,6 +41,7 @@ use Espo\Core\Utils\Config\SystemConfig; use Espo\Core\Utils\File\Manager as FileManager; +use Espo\Core\Utils\Theme\Direction; use Espo\Core\Utils\Theme\MetadataProvider as ThemeMetadataProvider; use Slim\Psr7\Response as Psr7Response; use Slim\ResponseEmitter; @@ -179,6 +180,7 @@ public function render(RenderParams $params): string additionalScripts: $params->scripts, pageTitle: $params->pageTitle, theme: $params->theme, + direction: $params->direction, ); } @@ -193,6 +195,7 @@ private function renderInternal( array $additionalScripts = [], ?string $pageTitle = null, ?string $theme = null, + ?Direction $direction = null, ): string { $runScript ??= $this->runScript; @@ -248,6 +251,8 @@ private function renderInternal( $this->themeMetadataProvider->getStylesheet($theme) : $this->themeManager->getStylesheet(); + $direction ??= $this->themeManager->getDirection(); + $data = [ 'applicationId' => $this->applicationId, 'apiUrl' => $this->apiUrl, @@ -256,6 +261,7 @@ private function renderInternal( 'appTimestamp' => $appTimestamp, 'loaderCacheTimestamp' => Json::encode($loaderCacheTimestamp), 'stylesheet' => $stylesheet, + 'direction' => $direction->value, 'theme' => Json::encode($theme), 'runScript' => $runScript, 'basePath' => $this->basePath, diff --git a/application/Espo/Core/Utils/Theme/Direction.php b/application/Espo/Core/Utils/Theme/Direction.php new file mode 100644 index 00000000000..02b98c4680f --- /dev/null +++ b/application/Espo/Core/Utils/Theme/Direction.php @@ -0,0 +1,36 @@ +. + * + * 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\Theme; + +enum Direction: string +{ + case Ltr = 'ltr'; + case Rtl = 'rtl'; +} diff --git a/application/Espo/Core/Utils/ThemeManager.php b/application/Espo/Core/Utils/ThemeManager.php index 9c76cbfddda..95355096749 100644 --- a/application/Espo/Core/Utils/ThemeManager.php +++ b/application/Espo/Core/Utils/ThemeManager.php @@ -29,6 +29,7 @@ namespace Espo\Core\Utils; +use Espo\Core\Utils\Theme\Direction; use Espo\Core\Utils\Theme\MetadataProvider; class ThemeManager @@ -53,6 +54,32 @@ public function getStylesheet(): string return $this->metadataProvider->getStylesheet($this->getName()); } + public function getDirection(): Direction + { + $rawDirection = $this->config->get('themeParams.direction'); + $direction = is_string($rawDirection) ? Direction::tryFrom($rawDirection) : null; + + if ($direction) { + return $direction; + } + + $rawDirection = $this->metadata->get( + ['themes', $this->getName(), 'params', 'direction', 'default'] + ); + + if (!is_string($rawDirection)) { + return Direction::Ltr; + } + + $direction = Direction::tryFrom($rawDirection); + + if (!$direction) { + return Direction::Ltr; + } + + return $direction; + } + public function getLogoSrc(): string { return $this->metadata->get(['themes', $this->getName(), 'logo']) ?? $this->defaultLogoSrc; diff --git a/application/Espo/EntryPoints/LeadCaptureForm.php b/application/Espo/EntryPoints/LeadCaptureForm.php index f7f967642a7..301f39fe88b 100644 --- a/application/Espo/EntryPoints/LeadCaptureForm.php +++ b/application/Espo/EntryPoints/LeadCaptureForm.php @@ -74,7 +74,8 @@ public function run(Request $request, Response $response): void $params = $params ->withFrameAncestors($leadCapture->getFormFrameAncestors()) ->withPageTitle($leadCapture->getFormTitle()) - ->withTheme($leadCapture->getFormTheme()); + ->withTheme($leadCapture->getFormTheme()) + ->withDirection($this->service->getDirection($leadCapture)); if ($captchaScript) { $params = $params->withScripts([new Script(source: $captchaScript)]); diff --git a/application/Espo/Resources/defaults/config.php b/application/Espo/Resources/defaults/config.php index cf636d188b6..fc5f47df812 100644 --- a/application/Espo/Resources/defaults/config.php +++ b/application/Espo/Resources/defaults/config.php @@ -191,7 +191,10 @@ 'b2cMode' => false, 'restrictedMode' => false, 'theme' => 'Espo', - 'themeParams' => (object) ['navbar' => 'side'], + 'themeParams' => (object) [ + 'navbar' => 'side', + 'direction' => 'ltr', + ], 'massEmailMaxPerHourCount' => 100, 'massEmailMaxPerBatchCount' => null, 'massEmailVerp' => false, diff --git a/application/Espo/Resources/i18n/en_US/Global.json b/application/Espo/Resources/i18n/en_US/Global.json index 58fb751335a..4833878c0c7 100644 --- a/application/Espo/Resources/i18n/en_US/Global.json +++ b/application/Espo/Resources/i18n/en_US/Global.json @@ -978,7 +978,6 @@ "Dark": "Dark", "Light": "Light", "Espo": "Espo", - "EspoRtl": "RTL", "Sakura": "Sakura", "Violet": "Violet", "Hazyblue": "Hazyblue", @@ -988,6 +987,10 @@ "side": "Side Navbar", "top": "Top Navbar" }, + "themeDirections": { + "ltr": "LTR", + "rtl": "RTL" + }, "fieldValidations": { "required": "Required", "maxCount": "Max Count", diff --git a/application/Espo/Resources/i18n/ur_IN/Global.json b/application/Espo/Resources/i18n/ur_IN/Global.json index 08d0fb99b61..7d01b729e10 100644 --- a/application/Espo/Resources/i18n/ur_IN/Global.json +++ b/application/Espo/Resources/i18n/ur_IN/Global.json @@ -825,7 +825,6 @@ "Sakura": "ساکورا", "Violet": "وایلیٹ", "Hazyblue": "ہیزی بلو", - "EspoRtl": "rtl", "Glass": "گلاس", "Light": "روشنی" }, @@ -920,4 +919,4 @@ "tabs": { "Stream": "ندی" } -} \ No newline at end of file +} diff --git a/application/Espo/Resources/metadata/themes/Espo.json b/application/Espo/Resources/metadata/themes/Espo.json index 23f161550a7..fa735191b12 100644 --- a/application/Espo/Resources/metadata/themes/Espo.json +++ b/application/Espo/Resources/metadata/themes/Espo.json @@ -10,6 +10,14 @@ "side", "top" ] + }, + "direction": { + "type": "enum", + "default": "ltr", + "options": [ + "ltr", + "rtl" + ] } }, "mappedParams": { diff --git a/application/Espo/Resources/metadata/themes/EspoRtl.json b/application/Espo/Resources/metadata/themes/EspoRtl.json deleted file mode 100644 index f76e989533d..00000000000 --- a/application/Espo/Resources/metadata/themes/EspoRtl.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "stylesheet": "client/css/espo/espo-rtl.css", - "stylesheetIframe": "client/css/espo/espo-rtl-iframe.css", - "logo": "client/img/logo-light.svg", - "params": { - "navbar": { - "type": "enum", - "default": "top", - "options": [ - "top", - "side" - ] - } - } -} diff --git a/application/Espo/Tools/LeadCapture/FormService.php b/application/Espo/Tools/LeadCapture/FormService.php index 5258b2c620a..a9678145bf7 100644 --- a/application/Espo/Tools/LeadCapture/FormService.php +++ b/application/Espo/Tools/LeadCapture/FormService.php @@ -37,6 +37,7 @@ use Espo\Core\Utils\DataCache; use Espo\Core\Utils\Language; use Espo\Core\Utils\Metadata; +use Espo\Core\Utils\Theme\Direction; use Espo\Core\Utils\Theme\MetadataProvider as ThemeMetadataProvider; use Espo\Core\Utils\ThemeManager; use Espo\Entities\Integration; @@ -50,6 +51,7 @@ class FormService { private const CACHE_KEY_PREFIX = 'leadCaptureForm'; + private const RTL_LANGUAGE_CODE_LIST = ['ar', 'fa', 'he', 'ur']; public function __construct( private EntityManager $entityManager, @@ -83,6 +85,14 @@ public function getData(string $id): array return [$leadCapture, $data, $captchaScript]; } + public function getDirection(LeadCapture $leadCapture): Direction + { + $language = $leadCapture->getFormLanguage() ?? $this->config->get('language') ?? 'en_US'; + $languageCode = strtolower(substr($language, 0, 2)); + + return in_array($languageCode, self::RTL_LANGUAGE_CODE_LIST, true) ? Direction::Rtl : Direction::Ltr; + } + /** * @return array */ diff --git a/client/modules/crm/src/views/calendar/calendar.js b/client/modules/crm/src/views/calendar/calendar.js index 50e0fcbc197..385890a8054 100644 --- a/client/modules/crm/src/views/calendar/calendar.js +++ b/client/modules/crm/src/views/calendar/calendar.js @@ -896,6 +896,7 @@ class CalendarView extends View { /** @type {CalendarOptions & Object.} */ const options = { + direction: this.getThemeManager().getDirection(), scrollTime: this.scrollHour + ':00', headerToolbar: false, slotLabelFormat: slotLabelFormat, diff --git a/client/modules/crm/src/views/calendar/timeline.js b/client/modules/crm/src/views/calendar/timeline.js index d6605b60794..0edc82e6502 100644 --- a/client/modules/crm/src/views/calendar/timeline.js +++ b/client/modules/crm/src/views/calendar/timeline.js @@ -595,6 +595,7 @@ class TimelineView extends View { const itemsDataSet = new DataSet(eventList); this.timeline = new Timeline($timeline.get(0), itemsDataSet, this.groupsDataSet, { + rtl: this.getThemeManager().getDirection() === 'rtl', dataAttributes: 'all', start: this.start.toDate(), end: this.end.toDate(), diff --git a/client/res/templates/record/detail.tpl b/client/res/templates/record/detail.tpl index 0f3b285fdd6..83e3235702e 100644 --- a/client/res/templates/record/detail.tpl +++ b/client/res/templates/record/detail.tpl @@ -7,7 +7,7 @@ role="group" >{{{buttons}}} {{#if navigateButtonsEnabled}} -
+
{{{editSideButtons}}}
diff --git a/client/res/templates/record/edit.tpl b/client/res/templates/record/edit.tpl index 479f666799c..2788d3ca8a2 100644 --- a/client/res/templates/record/edit.tpl +++ b/client/res/templates/record/edit.tpl @@ -7,7 +7,7 @@ role="group" >{{{buttons}}}
{{{sideButtons}}}
diff --git a/client/src/app.js b/client/src/app.js index 97c6184fabb..8e831ddc871 100644 --- a/client/src/app.js +++ b/client/src/app.js @@ -487,6 +487,7 @@ class App { this.loadStylesheet(); } + this.applyThemeDirection(); this.applyUserStyle(); if (this.anotherUser) { @@ -966,6 +967,8 @@ class App { this.preferences.clear(); this.acl.clear(); + this.applyThemeDirection(); + if (!silent) { this.storage.clear('user', 'auth'); this.storage.clear('user', 'anotherUser'); @@ -1023,6 +1026,16 @@ class App { element.setAttribute('href', path); } + /** + * @private + */ + applyThemeDirection() { + const direction = this.themeManager.getDirection(); + + document.documentElement.dir = direction; + document.body.dataset.direction = direction; + } + /** * @private */ diff --git a/client/src/theme-manager.js b/client/src/theme-manager.js index 2cbab3c425d..be7f3d61474 100644 --- a/client/src/theme-manager.js +++ b/client/src/theme-manager.js @@ -176,6 +176,17 @@ class ThemeManager { return link; } + /** + * Get the normalized document direction. + * + * @returns {'ltr'|'rtl'} + */ + getDirection() { + const direction = this.getParam('direction'); + + return direction === 'rtl' ? 'rtl' : 'ltr'; + } + /** * Get a theme parameter. * diff --git a/client/src/views/admin/user-interface.js b/client/src/views/admin/user-interface.js index e19eeeff0bc..ff57aa0de70 100644 --- a/client/src/views/admin/user-interface.js +++ b/client/src/views/admin/user-interface.js @@ -41,9 +41,13 @@ export default class extends SettingsEditRecordView { this.listenTo(this.model, 'change:scopeColorsDisabled', () => this.controlColorsField()); this.on('save', initialAttributes => { + const themeParams = this.model.get('themeParams') || {}; + const initialThemeParams = initialAttributes.themeParams || {}; + if ( this.model.get('theme') !== initialAttributes.theme || - (this.model.get('themeParams').navbar || {}) !== (initialAttributes.themeParams).navbar + themeParams.navbar !== initialThemeParams.navbar || + themeParams.direction !== initialThemeParams.direction ) { this.setConfirmLeaveOut(false); diff --git a/client/src/views/fields/wysiwyg.ts b/client/src/views/fields/wysiwyg.ts index 0fbf5ec19ea..0ad33295a4e 100644 --- a/client/src/views/fields/wysiwyg.ts +++ b/client/src/views/fields/wysiwyg.ts @@ -455,7 +455,8 @@ class WysiwygFieldView < } // noinspection HtmlRequiredTitleElement - const documentHtml = `${headHtml}${bodyHtml}` + const direction = this.getThemeManager().getDirection(); + const documentHtml = `${headHtml}${bodyHtml}` // @ts-ignore documentElement.write(documentHtml); diff --git a/client/src/views/lead-capture/fields/form-theme.js b/client/src/views/lead-capture/fields/form-theme.js index d634222348e..0380c67fb78 100644 --- a/client/src/views/lead-capture/fields/form-theme.js +++ b/client/src/views/lead-capture/fields/form-theme.js @@ -32,14 +32,9 @@ export default class FormThemeFieldView extends EnumFieldView { setupOptions() { const list = Object.keys(this.getMetadata().get('themes') || {}) - .sort((v1, v2) => { - if (v2 === 'EspoRtl') { - return -1; - } - - return this.translate(v1, 'theme') - .localeCompare(this.translate(v2, 'theme')); - }); + .sort( + (v1, v2) => this.translate(v1, 'theme').localeCompare(this.translate(v2, 'theme')) + ); this.params.options = ['', ...list]; } diff --git a/client/src/views/preferences/fields/theme.js b/client/src/views/preferences/fields/theme.js index d2dde8b8dc5..8b2d0aa96d8 100644 --- a/client/src/views/preferences/fields/theme.js +++ b/client/src/views/preferences/fields/theme.js @@ -32,13 +32,7 @@ export default class extends ThemeSettingsFieldView { setupOptions() { this.params.options = Object.keys(this.getMetadata().get('themes') || {}) - .sort((v1, v2) => { - if (v2 === 'EspoRtl') { - return -1; - } - - return this.translate(v1, 'themes').localeCompare(this.translate(v2, 'themes')); - }); + .sort((v1, v2) => this.translate(v1, 'themes').localeCompare(this.translate(v2, 'themes'))); this.params.options.unshift(''); } @@ -56,8 +50,16 @@ export default class extends ThemeSettingsFieldView { afterRenderDetail() { const navbar = this.getNavbarValue() || this.getDefaultNavbar(); + const direction = this.getDirectionValue() || this.getDefaultDirection(); + + [ + [navbar, 'themeNavbars'], + [direction, 'themeDirections'], + ].forEach(([value, category]) => { + if (!value) { + return; + } - if (navbar) { this.$el .append(' ') .append( @@ -65,8 +67,8 @@ export default class extends ThemeSettingsFieldView { ) .append(' ') .append( - $('').text(this.translate(navbar, 'themeNavbars')) + $('').text(this.translate(value, category)) ) - } + }); } } diff --git a/client/src/views/preferences/record/edit.js b/client/src/views/preferences/record/edit.js index 38ef63bce78..5eab1027a93 100644 --- a/client/src/views/preferences/record/edit.js +++ b/client/src/views/preferences/record/edit.js @@ -150,6 +150,7 @@ class PreferencesEditRecordView extends EditRecordView { this.model.get('language') !== initialAttributes.language || this.model.get('theme') !== initialAttributes.theme || (this.model.get('themeParams') || {}).navbar !== (initialAttributes.themeParams || {}).navbar || + (this.model.get('themeParams') || {}).direction !== (initialAttributes.themeParams || {}).direction || this.model.get('pageContentWidth') !== initialAttributes.pageContentWidth ) { this.setConfirmLeaveOut(false); diff --git a/client/src/views/settings/fields/theme.js b/client/src/views/settings/fields/theme.js index 577e4e00d7e..e970c210356 100644 --- a/client/src/views/settings/fields/theme.js +++ b/client/src/views/settings/fields/theme.js @@ -54,6 +54,13 @@ export default class ThemeSettingsFieldView extends EnumFieldView { {{/if}} + {{#if directionOptionList.length}} +
+ +
+ {{/if}} ` @@ -68,6 +75,14 @@ export default class ThemeSettingsFieldView extends EnumFieldView { data.navbarTranslatedOptions[item] = this.translate(item, 'themeNavbars'); }); + data.directionOptionList = this.getDirectionOptionList(); + data.direction = this.getDirectionValue() || this.getDefaultDirection(); + + data.directionTranslatedOptions = {}; + data.directionOptionList.forEach(item => { + data.directionTranslatedOptions[item] = this.translate(item, 'themeDirections'); + }); + return data; } @@ -88,10 +103,13 @@ export default class ThemeSettingsFieldView extends EnumFieldView { afterRenderEdit() { this.$navbar = this.$el.find('[data-name="themeNavbar"]'); + this.$direction = this.$el.find('[data-name="themeDirection"]'); this.$navbar.on('change', () => this.trigger('change')); + this.$direction.on('change', () => this.trigger('change')); Select.init(this.$navbar); + Select.init(this.$direction); } /** @@ -152,6 +170,64 @@ export default class ThemeSettingsFieldView extends EnumFieldView { return defs.default || null; } + /** + * @protected + * @return {string} + */ + getDirectionValue() { + const params = this.model.get('themeParams') || {}; + + return params.direction; + } + + /** + * @protected + * @return {Record|null} + */ + getDirectionDefs() { + if (!this.themeManager) { + return null; + } + + const params = this.themeManager.getParam('params'); + + if (!params || !params.direction) { + return null; + } + + return Espo.Utils.cloneDeep(params.direction); + } + + /** + * @private + * @return {string[]} + */ + getDirectionOptionList() { + const defs = this.getDirectionDefs(); + + if (!defs) { + return []; + } + + const optionList = defs.options || []; + + if (!optionList.length || optionList.length === 1) { + return []; + } + + return optionList; + } + + /** + * @protected + * @return {string|null} + */ + getDefaultDirection() { + const defs = this.getDirectionDefs() || {}; + + return defs.default || null; + } + /** * @private */ @@ -178,14 +254,9 @@ export default class ThemeSettingsFieldView extends EnumFieldView { setupOptions() { this.params.options = Object.keys(this.getMetadata().get('themes') || {}) - .sort((v1, v2) => { - if (v2 === 'EspoRtl') { - return -1; - } - - return this.translate(v1, 'theme') - .localeCompare(this.translate(v2, 'theme')); - }); + .sort( + (v1, v2) => this.translate(v1, 'theme').localeCompare(this.translate(v2, 'theme')) + ); } fetch() { @@ -197,6 +268,10 @@ export default class ThemeSettingsFieldView extends EnumFieldView { params.navbar = this.$navbar.val(); } + if (this.$direction.length) { + params.direction = this.$direction.val(); + } + data.themeParams = params; return data; diff --git a/client/src/views/site/master.js b/client/src/views/site/master.js index 2df1ccf8b1c..c59173cd4c5 100644 --- a/client/src/views/site/master.js +++ b/client/src/views/site/master.js @@ -118,6 +118,7 @@ class MasterSiteView extends View { body.dataset[param] = this.getThemeManager().getParam(param); } + body.dataset.direction = this.getThemeManager().getDirection(); body.dataset.isDark = this.getThemeManager().getParam('isDark') ?? false; body.dataset.themeName = this.getThemeManager().getName(); diff --git a/frontend/less/espo-rtl/bootstrap-rtl/alerts-rtl.less b/frontend/less/espo-rtl/bootstrap-rtl/alerts-rtl.less deleted file mode 100644 index 2d6aad80829..00000000000 --- a/frontend/less/espo-rtl/bootstrap-rtl/alerts-rtl.less +++ /dev/null @@ -1,18 +0,0 @@ -// -// RTL Alerts -// -------------------------------------------------- - - -// Dismissable alerts -// -// Expand the left padding and account for the close button's positioning. - -.alert-dismissable, // The misspelled .alert-dismissable was deprecated in 3.2.0. -.alert-dismissible { - - // Adjust close link position - .close { - right: auto; - left: -21px; - } -} diff --git a/frontend/less/espo-rtl/bootstrap-rtl/badges-rtl.less b/frontend/less/espo-rtl/bootstrap-rtl/badges-rtl.less deleted file mode 100644 index ba0f4292e08..00000000000 --- a/frontend/less/espo-rtl/bootstrap-rtl/badges-rtl.less +++ /dev/null @@ -1,21 +0,0 @@ -// -// RTL Badges -// -------------------------------------------------- - - -// Base class -.badge { - - .nav-pills > li > a > & { - margin-left: 0px; - margin-right: 3px; - } - - .list-group-item > & { - float: left; - } - .list-group-item > & + & { - margin-left: 5px; - margin-right: auto; - } -} diff --git a/frontend/less/espo-rtl/bootstrap-rtl/bootstrap-flipped.less b/frontend/less/espo-rtl/bootstrap-rtl/bootstrap-flipped.less deleted file mode 100644 index d04b46496ff..00000000000 --- a/frontend/less/espo-rtl/bootstrap-rtl/bootstrap-flipped.less +++ /dev/null @@ -1,64 +0,0 @@ -// -------------------------------------------------- -// Deprecated flips for Bootstrap RTL v3.x -// Morteza Ansarinia -// http://github.com/morteza/bootstrap-rtl -// -------------------------------------------------- - -// Regular, semantically correct Bootstrap RTL -// Core variables and mixins imported from the latest original bootstrap -@import "../bootstrap/less/variables.less"; -@import "variables-rtl.less"; -@import "mixins-flipped.less"; - -// ---- Single side border-radius ---- - -.border-right-radius(@radius) { - border-bottom-left-radius: @radius; - border-top-left-radius: @radius; -} -.border-left-radius(@radius) { - border-bottom-right-radius: @radius; - border-top-right-radius: @radius; -} - -// Reset and dependencies -@import "normalize-rtl.less"; - - -// Core CSS - -@import "type-rtl.less"; - -@import "grid-rtl.less"; -@import "tables-rtl.less"; -@import "forms-rtl.less"; - - -// Components -//@import "component-animations.less"; -@import "dropdowns-rtl.less"; -@import "button-groups-rtl.less"; -@import "input-groups-rtl.less"; -@import "navs-rtl.less"; -@import "navbar-rtl.less"; - -@import "badges-rtl.less"; -@import "alerts-rtl.less"; -@import "progress-bars-rtl.less"; -@import "media-rtl.less"; -@import "list-group-rtl.less"; -@import "panels-rtl.less"; -@import "responsive-embed-rtl.less"; - -@import "close-rtl.less"; - - -@import "modals-rtl.less"; - -@import "popovers-rtl.less"; -@import "carousel-rtl.less"; - - -@import "utilities-rtl.less"; - -@import "flipped.less"; diff --git a/frontend/less/espo-rtl/bootstrap-rtl/bootstrap-rtl.less b/frontend/less/espo-rtl/bootstrap-rtl/bootstrap-rtl.less deleted file mode 100644 index 725256f633e..00000000000 --- a/frontend/less/espo-rtl/bootstrap-rtl/bootstrap-rtl.less +++ /dev/null @@ -1,49 +0,0 @@ -// -------------------------------------------------- -// Right-to-Left (RTL) Theme for Bootstrap 3.x -// Morteza Ansarinia -// http://github.com/morteza/bootstrap-rtl -// -------------------------------------------------- - -// Core variables and mixins imported from the latest original bootstrap -@import "../../espo/bootstrap/variables.less"; -@import "variables-rtl.less"; -@import "mixins-rtl.less"; - -// Reset and dependencies -@import "normalize-rtl.less"; -//@import "print.less"; -//@import "glyphicons.less"; - -// Core CSS -//@import "scaffolding.less"; -@import "type-rtl.less"; -//@import "code.less"; -@import "grid-rtl.less"; -@import "tables-rtl.less"; -@import "forms-rtl.less"; -//@import "buttons.less"; - -// Components -//@import "component-animations.less"; -@import "dropdowns-rtl.less"; -@import "button-groups-rtl.less"; -@import "input-groups-rtl.less"; -@import "navs-rtl.less"; -@import "navbar-rtl.less"; -@import "badges-rtl.less"; -@import "alerts-rtl.less"; -@import "progress-bars-rtl.less"; -@import "media-rtl.less"; -@import "list-group-rtl.less"; -@import "panels-rtl.less"; -@import "responsive-embed-rtl.less"; -@import "close-rtl.less"; - -// Components w/ JavaScript -@import "modals-rtl.less"; -@import "popovers-rtl.less"; -@import "carousel-rtl.less"; - -// Utility classes -@import "utilities-rtl.less"; -//@import "responsive-utilities.less"; diff --git a/frontend/less/espo-rtl/bootstrap-rtl/button-groups-rtl.less b/frontend/less/espo-rtl/bootstrap-rtl/button-groups-rtl.less deleted file mode 100644 index f7064008cae..00000000000 --- a/frontend/less/espo-rtl/bootstrap-rtl/button-groups-rtl.less +++ /dev/null @@ -1,94 +0,0 @@ -// -// RTL Button groups -// -------------------------------------------------- - -// Make the div behave like a button -.btn-group, -.btn-group-vertical { - > .btn { - float: right; - } -} - -// Prevent double borders when buttons are next to each other -.btn-group { - .btn + .btn, - .btn + .btn-group, - .btn-group + .btn, - .btn-group + .btn-group { - margin-right: -1px; - margin-left: 0px; - } -} - -// Optional: Group multiple button groups together for a toolbar -.btn-toolbar { - margin-right: -5px; // Offset the first child's margin - margin-left: 0px; - - .btn-group, - .input-group { - float: right; - } - > .btn, - > .btn-group, - > .input-group { - margin-right: 5px; - margin-left: 0px; - } -} - -// Set corners individual because sometimes a single button can be in a .btn-group and we need :first-child and :last-child to both match -.btn-group > .btn:first-child { - margin-right: 0; - &:not(:last-child):not(.dropdown-toggle) { - .border-left-radius(0); - } -} -// Need .dropdown-toggle since :last-child doesn't apply given a .dropdown-menu immediately after it -.btn-group > .btn:last-child:not(:first-child), -.btn-group > .dropdown-toggle:not(:first-child) { - .border-right-radius(0); -} - -// Custom edits for including btn-groups within btn-groups (useful for including dropdown buttons within a btn-group) -.btn-group > .btn-group { - float: right; -} - -.btn-group.btn-group-justified > .btn, -.btn-group.btn-group-justified > .btn-group { - float: none; -} - -.btn-group > .btn-group:not(:first-child):not(:last-child) > .btn { - border-radius: 0; -} -.btn-group > .btn-group:first-child { - > .btn:last-child, - > .dropdown-toggle { - .border-left-radius(0); - } -} -.btn-group > .btn-group:last-child > .btn:first-child { - .border-right-radius(0); -} - -// Reposition the caret -.btn .caret { - margin-right: 0; -} - -// Vertical button groups -// ---------------------- - -.btn-group-vertical { - - > .btn + .btn, - > .btn + .btn-group, - > .btn-group + .btn, - > .btn-group + .btn-group { - margin-top: -1px; - margin-right: 0; - } -} diff --git a/frontend/less/espo-rtl/bootstrap-rtl/carousel-rtl.less b/frontend/less/espo-rtl/bootstrap-rtl/carousel-rtl.less deleted file mode 100644 index e5650ae1f66..00000000000 --- a/frontend/less/espo-rtl/bootstrap-rtl/carousel-rtl.less +++ /dev/null @@ -1,76 +0,0 @@ -// -// RTL Carousel -// -------------------------------------------------- - - -// Left/right controls for nav -// --------------------------- - -.carousel-control { - right: 0; - bottom: 0; - - // Set gradients for backgrounds - &.left { - right: auto; - left: 0; - #gradient > .horizontal(@start-color: rgba(0,0,0,.5); @end-color: rgba(0,0,0,.0001)); - } - &.right { - left: auto; - right: 0; - #gradient > .horizontal(@start-color: rgba(0,0,0,.0001); @end-color: rgba(0,0,0,.5)); - } - - .icon-prev, - .glyphicon-chevron-left { - left: 50%; - right: auto; - margin-right: -10px; - } - .icon-next, - .glyphicon-chevron-right { - right: 50%; - left: auto; - margin-left: -10px; - } -} - -// Optional indicator pips -// -// Add an unordered list with the following class and add a list item for each -// slide your carousel holds. - -.carousel-indicators { - right: 50%; - left: 0; - margin-right: -30%; - margin-left: 0; - padding-left: 0; - -} - -// Scale up controls for tablets and up -@media screen and (min-width: @screen-sm-min) { - - // Scale up the controls a smidge - .carousel-control { - .glyphicon-chevron-left, - .icon-prev { - margin-left: 0; - margin-right: -15px; - } - .glyphicon-chevron-right, - .icon-next { - margin-left: 0; - margin-right: -15px; - } - } - - // Show and left align the captions - .carousel-caption { - left: 20%; - right: 20%; - padding-bottom: 30px; - } -} diff --git a/frontend/less/espo-rtl/bootstrap-rtl/close-rtl.less b/frontend/less/espo-rtl/bootstrap-rtl/close-rtl.less deleted file mode 100644 index b61439574c4..00000000000 --- a/frontend/less/espo-rtl/bootstrap-rtl/close-rtl.less +++ /dev/null @@ -1,8 +0,0 @@ -// -// RTL Close icons -// -------------------------------------------------- - - -.close { - float: left; -} diff --git a/frontend/less/espo-rtl/bootstrap-rtl/dropdowns-rtl.less b/frontend/less/espo-rtl/bootstrap-rtl/dropdowns-rtl.less deleted file mode 100644 index 808252c7fbf..00000000000 --- a/frontend/less/espo-rtl/bootstrap-rtl/dropdowns-rtl.less +++ /dev/null @@ -1,63 +0,0 @@ -// -// RTL Dropdown menus -// -------------------------------------------------- - -// Dropdown arrow/caret -.caret { - margin-right: 2px; - margin-left: 0; -} - -// The dropdown menu (ul) -.dropdown-menu { - right: auto; - left: 0; - float: left; - text-align: right; // Ensures proper alignment if parent has it changed (e.g., modal footer) - - // Aligns the dropdown menu to right - // - // Deprecated as of 3.1.0 in favor of `.dropdown-menu-[dir]` - &.pull-right { - left: 0; - right: auto; - float: right; - } -} - -// Menu positioning -// -// Add extra class to `.dropdown-menu` to flip the alignment of the dropdown -// menu with the parent. -.dropdown-menu-right { - left: auto; // Reset the default from `.dropdown-menu` - right: 0; -} -// With v3, we enabled auto-flipping if you have a dropdown within a right -// aligned nav component. To enable the undoing of that, we provide an override -// to restore the default dropdown menu alignment. -// -// This is only for left-aligning a dropdown menu within a `.navbar-right` or -// `.pull-right` nav component. -.dropdown-menu-left { - left: 0; - right: auto; -} - -// Component alignment -// -// Reiterate per navbar.less and the modified component alignment there. - -@media (min-width: @grid-float-breakpoint) { - .navbar-right { - .dropdown-menu { - .dropdown-menu-right(); - } - // Necessary for overrides of the default right aligned menu. - // Will remove come v4 in all likelihood. - .dropdown-menu-left { - .dropdown-menu-left(); - } - } -} - diff --git a/frontend/less/espo-rtl/bootstrap-rtl/flipped.less b/frontend/less/espo-rtl/bootstrap-rtl/flipped.less deleted file mode 100644 index 4d5a57a6d6a..00000000000 --- a/frontend/less/espo-rtl/bootstrap-rtl/flipped.less +++ /dev/null @@ -1,83 +0,0 @@ -// --------------------------------------------- -// Flipped classes -// --------------------------------------------- - -// ---- Utilities ---- -.pull-right { - float: left !important; -} -.pull-left { - float: right !important; -} - -//TODO carousel -//TODO Glyphicons - -// ---- Dropdown ---- -.dropdown-menu-left { - left: auto; - right: 0; -} - -// ---- Media ---- -.media-right, -.media > .pull-right { - padding-right: 10px; - padding-left: initial; -} - -.media-left, -.media > .pull-left { - padding-left: 10px; - padding-right: initial; -} - -// ---- Navbar ---- -@media (min-width: @grid-float-breakpoint) { - .navbar-left { .pull-left(); } - .navbar-right { - .pull-right(); - - ~ .navbar-right { - margin-left: 0; - margin-right: initial; - } - } -} - -// ---- Tooltip ---- -.tooltip { - &.top-left .tooltip-arrow { - left: @tooltip-arrow-width; - } - &.top-right .tooltip-arrow { - right: @tooltip-arrow-width; - } - &.right .tooltip-arrow { - right: 0; - border-left-color: @tooltip-arrow-color; - } - &.left .tooltip-arrow { - left: 0; - border-right-color: @tooltip-arrow-color; - } - &.bottom-left .tooltip-arrow { - left: @tooltip-arrow-width; - } - &.bottom-right .tooltip-arrow { - right: @tooltip-arrow-width; - } -} - -// ---- Type ---- -.text-left { text-align: right; } -.text-right { text-align: left; } - -//DEPRECATED -blockquote.pull-right { - padding-left: 15px; - padding-right: 0; - border-left: 5px solid @blockquote-border-color; - border-right: 0; - text-align: left; -} diff --git a/frontend/less/espo-rtl/bootstrap-rtl/forms-rtl.less b/frontend/less/espo-rtl/bootstrap-rtl/forms-rtl.less deleted file mode 100644 index dce7fbad075..00000000000 --- a/frontend/less/espo-rtl/bootstrap-rtl/forms-rtl.less +++ /dev/null @@ -1,102 +0,0 @@ -// -// RTL Forms -// -------------------------------------------------- - - -.radio, -.checkbox { - - label { - padding-right: 20px; - padding-left: initial; - } -} -.radio input[type="radio"], -.radio-inline input[type="radio"], -.checkbox input[type="checkbox"], -.checkbox-inline input[type="checkbox"] { - margin-right: -20px; - margin-left: auto; -} - -// Radios and checkboxes on same line -.radio-inline, -.checkbox-inline { - padding-right: 20px; - padding-left: 0; -} -.radio-inline + .radio-inline, -.checkbox-inline + .checkbox-inline { - margin-right: 10px; // space out consecutive inline controls - margin-left: 0; -} - -.has-feedback { - - // Ensure icons don't overlap text - .form-control { - padding-left: calc(var(--input-height-base) * 1.25); - padding-right: 12px; - } -} -// Feedback icon (requires .glyphicon classes) -.form-control-feedback { - left: 0; - right: auto; -} - -// Inline forms -// -// Make forms appear inline(-block) by adding the `.form-inline` class. Inline -// forms begin stacked on extra small (mobile) devices and then go inline when -// viewports reach <768px. -// -// Requires wrapping inputs and labels with `.form-group` for proper display of -// default HTML form controls and our custom form controls (e.g., input groups). -// -// Heads up! This is mixin-ed into `.navbar-form` in navbars.less. - -.form-inline { - - // Kick in the inline - @media (min-width: @screen-sm-min) { - - label { - padding-right: 0; - padding-left: initial; - } - - .radio input[type="radio"], - .checkbox input[type="checkbox"] { - margin-right: 0; - margin-left: auto; - } - - } -} - - -// Horizontal forms -// -// Horizontal forms are built on grid classes and allow you to create forms with -// labels on the left and inputs on the right. - -.form-horizontal { - - // Reset spacing and right align labels, but scope to media queries so that - // labels on narrow viewports stack the same as a default form example. - @media (min-width: @screen-sm-min) { - .control-label { - text-align: left; - } - } - - // Validation states - // - // Reposition the icon because it's now within a grid column and columns have - // `position: relative;` on them. Also accounts for the grid gutter padding. - .has-feedback .form-control-feedback { - left: var(--grid-gutter-width-half); - right: auto; - } -} diff --git a/frontend/less/espo-rtl/bootstrap-rtl/grid-rtl.less b/frontend/less/espo-rtl/bootstrap-rtl/grid-rtl.less deleted file mode 100644 index e482bcbf6de..00000000000 --- a/frontend/less/espo-rtl/bootstrap-rtl/grid-rtl.less +++ /dev/null @@ -1,45 +0,0 @@ -// -// RTL Grid system -// -------------------------------------------------- - -// Columns -// -// Common styles for small and large grid columns - -.make-rtl-grid-columns(); - - -// Extra small grid -// -// Columns, offsets, pushes, and pulls for extra small devices like -// smartphones. - -.make-rtl-grid(xs); - - -// Small grid -// -// Columns, offsets, pushes, and pulls for the small device range, from phones -// to tablets. - -@media (min-width: @screen-sm-min) { - .make-rtl-grid(sm); -} - - -// Medium grid -// -// Columns, offsets, pushes, and pulls for the desktop device range. - -@media (min-width: @screen-md-min) { - .make-rtl-grid(md); -} - - -// Large grid -// -// Columns, offsets, pushes, and pulls for the large desktop device range. - -@media (min-width: @screen-lg-min) { - .make-rtl-grid(lg); -} diff --git a/frontend/less/espo-rtl/bootstrap-rtl/input-groups-rtl.less b/frontend/less/espo-rtl/bootstrap-rtl/input-groups-rtl.less deleted file mode 100644 index f04c21d2bf6..00000000000 --- a/frontend/less/espo-rtl/bootstrap-rtl/input-groups-rtl.less +++ /dev/null @@ -1,75 +0,0 @@ -// -// Input groups -// -------------------------------------------------- - -// Base styles -// ------------------------- -.input-group { - .form-control { - // IE9 fubars the placeholder attribute in text inputs and the arrows on - // select elements in input groups. To fix it, we float the input. Details: - // https://github.com/twbs/bootstrap/issues/11561#issuecomment-28936855 - float: right; - } -} - -// Reset rounded corners -.input-group .form-control:first-child, -.input-group-addon:first-child, -.input-group-btn:first-child > .btn, -.input-group-btn:first-child > .btn-group > .btn, -.input-group-btn:first-child > .dropdown-toggle, -.input-group-btn:last-child > .btn:not(:last-child):not(.dropdown-toggle), -.input-group-btn:last-child > .btn-group:not(:last-child) > .btn { - .border-right-radius(var(--border-radius)); - .border-left-radius(0); -} -.input-group-addon:first-child { - border-left: 0px; - border-right: 1px solid; -} - -.input-group .form-control:last-child, -.input-group-addon:last-child, -.input-group-btn:last-child > .btn, -.input-group-btn:last-child > .btn-group > .btn, -.input-group-btn:last-child > .dropdown-toggle, -.input-group-btn:first-child > .btn:not(:first-child), -.input-group-btn:first-child > .btn-group:not(:first-child) > .btn { - .border-right-radius(0); -} -.input-group-addon:last-child { - border-left-width: 1px; - border-left-style: solid; - border-right: 0px; -} - -// Button input groups -// ------------------------- -.input-group-btn { - - // Negative margin for spacing, position for bringing hovered/focused/actived - // element above the siblings. - > .btn { - + .btn { - margin-right: -1px; - margin-left: auto; - } - } - - // Negative margin to only have a 1px border between the two - &:first-child { - > .btn, - > .btn-group { - margin-left: -1px; - margin-right: auto; - } - } - &:last-child { - > .btn, - > .btn-group { - margin-right: -1px; - margin-left: auto; - } - } -} diff --git a/frontend/less/espo-rtl/bootstrap-rtl/list-group-rtl.less b/frontend/less/espo-rtl/bootstrap-rtl/list-group-rtl.less deleted file mode 100644 index 67734f7e446..00000000000 --- a/frontend/less/espo-rtl/bootstrap-rtl/list-group-rtl.less +++ /dev/null @@ -1,14 +0,0 @@ -// -// List groups -// -------------------------------------------------- - - -// Base class -// -// Easily usable on
    ,
      , or
      . - -.list-group { - padding-right: 0; // reset padding because ul and ol - padding-left: initial; -} - diff --git a/frontend/less/espo-rtl/bootstrap-rtl/media-rtl.less b/frontend/less/espo-rtl/bootstrap-rtl/media-rtl.less deleted file mode 100644 index 9dd84247ff8..00000000000 --- a/frontend/less/espo-rtl/bootstrap-rtl/media-rtl.less +++ /dev/null @@ -1,47 +0,0 @@ -// RTL Media objects -// -------------------------------------------------- - - -// Media image alignment -// ------------------------- - -.media { - > .pull-left { - margin-right: 10px; - &.flip { - margin-right: 0; - margin-left: 10px; - } - } - > .pull-right { - margin-left: 10px; - &.flip { - margin-left: 0; - margin-right: 10px; - } - } -} - -.media-right, -.media > .pull-right { - padding-right: 10px; - padding-left: initial; -} - -.media-left, -.media > .pull-left { - padding-left: 10px; - padding-right: initial; -} - - -// Media list variation -// ------------------------- - -// Undo default ul/ol styles -.media-list { - padding-right: 0; - padding-left: initial; - list-style: none; -} - diff --git a/frontend/less/espo-rtl/bootstrap-rtl/mixins-flipped.less b/frontend/less/espo-rtl/bootstrap-rtl/mixins-flipped.less deleted file mode 100644 index 96b617f3b30..00000000000 --- a/frontend/less/espo-rtl/bootstrap-rtl/mixins-flipped.less +++ /dev/null @@ -1,31 +0,0 @@ -// Mixins -// -------------------------------------------------- - -// Utilities -@import "../bootstrap/less/mixins/hide-text.less"; -@import "../bootstrap/less/mixins/opacity.less"; -@import "../bootstrap/less/mixins/image.less"; -@import "../bootstrap/less/mixins/reset-filter.less"; -@import "../bootstrap/less/mixins/resize.less"; -@import "../bootstrap/less/mixins/responsive-visibility.less"; -@import "../bootstrap/less/mixins/size.less"; -@import "../bootstrap/less/mixins/tab-focus.less"; -@import "../bootstrap/less/mixins/text-overflow.less"; -@import "../bootstrap/less/mixins/vendor-prefixes.less"; - -// Components -@import "../bootstrap/less/mixins/buttons.less"; -@import "../bootstrap/less/mixins/panels.less"; -@import "../bootstrap/less/mixins/nav-divider.less"; -@import "../bootstrap/less/mixins/forms.less"; -@import "../bootstrap/less/mixins/progress-bar.less"; - -// Skins -@import "mixins/border-radius-flipped.less"; //FLIPPED -@import "mixins/gradients-rtl.less"; // RTL - -// Layout -@import "../bootstrap/less/mixins/clearfix.less"; -@import "../bootstrap/less/mixins/center-block.less"; -@import "mixins/grid-framework-rtl.less"; // RTL -@import "mixins/grid-rtl.less"; // RTL diff --git a/frontend/less/espo-rtl/bootstrap-rtl/mixins-rtl.less b/frontend/less/espo-rtl/bootstrap-rtl/mixins-rtl.less deleted file mode 100644 index 855d26b1275..00000000000 --- a/frontend/less/espo-rtl/bootstrap-rtl/mixins-rtl.less +++ /dev/null @@ -1,31 +0,0 @@ -// Mixins -// -------------------------------------------------- - -// Utilities -@import "../../espo/bootstrap/mixins/hide-text.less"; -@import "../../espo/bootstrap/mixins/opacity.less"; -@import "../../espo/bootstrap/mixins/image.less"; -@import "../../espo/bootstrap/mixins/reset-filter.less"; -@import "../../espo/bootstrap/mixins/resize.less"; -@import "../../espo/bootstrap/mixins/responsive-visibility.less"; -@import "../../espo/bootstrap/mixins/size.less"; -@import "../../espo/bootstrap/mixins/tab-focus.less"; -@import "../../espo/bootstrap/mixins/text-overflow.less"; -@import "../../espo/bootstrap/mixins/vendor-prefixes.less"; - -// Components -@import "../../espo/bootstrap/mixins/buttons.less"; -@import "../../espo/bootstrap/mixins/panels.less"; -@import "../../espo/bootstrap/mixins/nav-divider.less"; -@import "../../espo/bootstrap/mixins/forms.less"; -@import "../../espo/bootstrap/mixins/progress-bar.less"; - -// Skins -@import "../../espo/bootstrap/mixins/border-radius.less"; -@import "mixins/gradients-rtl.less"; // RTL - -// Layout -@import "../../espo/bootstrap/mixins/clearfix.less"; -@import "../../espo/bootstrap/mixins/center-block.less"; -@import "mixins/grid-framework-rtl.less"; // RTL -@import "mixins/grid-rtl.less"; // RTL diff --git a/frontend/less/espo-rtl/bootstrap-rtl/mixins/border-radius-flipped.less b/frontend/less/espo-rtl/bootstrap-rtl/mixins/border-radius-flipped.less deleted file mode 100644 index bddaeca3b85..00000000000 --- a/frontend/less/espo-rtl/bootstrap-rtl/mixins/border-radius-flipped.less +++ /dev/null @@ -1,10 +0,0 @@ -// Single side flipped border-radius - -.border-right-radius(@radius) { - border-bottom-left-radius: @radius; - border-top-left-radius: @radius; -} -.border-left-radius(@radius) { - border-bottom-right-radius: @radius; - border-top-right-radius: @radius; -} diff --git a/frontend/less/espo-rtl/bootstrap-rtl/mixins/gradients-rtl.less b/frontend/less/espo-rtl/bootstrap-rtl/mixins/gradients-rtl.less deleted file mode 100644 index 3a69ad97978..00000000000 --- a/frontend/less/espo-rtl/bootstrap-rtl/mixins/gradients-rtl.less +++ /dev/null @@ -1,24 +0,0 @@ -// RTL Gradients - -#gradient { - - // Horizontal gradient, from right to left - // - // Creates two color stops, start and end, by specifying a color and position for each color stop. - // Color stops are not available in IE9 and below. - .horizontal(@start-color: #333; @end-color: #555; @start-percent: 0%; @end-percent: 100%) { - background-image: -webkit-linear-gradient(left, color-stop(@start-color @start-percent), color-stop(@end-color @end-percent)); // Safari 5.1-6, Chrome 10+ - background-image: -o-linear-gradient(left, @start-color @start-percent, @end-color @end-percent); // Opera 12 - background-image: linear-gradient(to right, @start-color @start-percent, @end-color @end-percent); // Standard, IE10, Firefox 16+, Opera 12.10+, Safari 7+, Chrome 26+ - background-repeat: repeat-x; - filter: e(%("progid:DXImageTransform.Microsoft.gradient(startColorstr='%d', endColorstr='%d', GradientType=1)",argb(@start-color),argb(@end-color))); // IE9 and down - } - - .horizontal-three-colors(@start-color: #c3325f; @mid-color: #7a43b6; @color-stop: 50%; @end-color: #00b3ee) { - background-image: -webkit-linear-gradient(left, @start-color, @mid-color @color-stop, @end-color); - background-image: -o-linear-gradient(left, @start-color, @mid-color @color-stop, @end-color); - background-image: linear-gradient(to right, @start-color, @mid-color @color-stop, @end-color); - background-repeat: no-repeat; - filter: e(%("progid:DXImageTransform.Microsoft.gradient(startColorstr='%d', endColorstr='%d', GradientType=1)",argb(@start-color),argb(@end-color))); // IE9 and down, gets no color-stop at all for proper fallback - } -} diff --git a/frontend/less/espo-rtl/bootstrap-rtl/mixins/grid-framework-rtl.less b/frontend/less/espo-rtl/bootstrap-rtl/mixins/grid-framework-rtl.less deleted file mode 100644 index 3d36c62a2d5..00000000000 --- a/frontend/less/espo-rtl/bootstrap-rtl/mixins/grid-framework-rtl.less +++ /dev/null @@ -1,96 +0,0 @@ -// RTL Framework grid generation -// -// Used only by Bootstrap to generate the correct number of grid classes given -// any value of `@grid-columns`. - -.make-rtl-grid-columns() { - // Common styles for all sizes of grid columns, widths 1-12 - .col(@index) { // initial - @item: ~".col-xs-@{index}, .col-sm-@{index}, .col-md-@{index}, .col-lg-@{index}"; - .col((@index + 1), @item); - } - .col(@index, @list) when (@index =< @grid-columns) { // general; "=<" isn't a typo - @item: ~".col-xs-@{index}, .col-sm-@{index}, .col-md-@{index}, .col-lg-@{index}"; - .col((@index + 1), ~"@{list}, @{item}"); - } - .col(@index, @list) when (@index > @grid-columns) { // terminal - @{list} { - position: relative; - // Prevent columns from collapsing when empty - min-height: 1px; - // Inner gutter via padding - padding-left: var(--grid-gutter-width-half); - padding-right: var(--grid-gutter-width-half); - } - } - .col(1); // kickstart it -} - -.float-rtl-grid-columns(@class) { - .col(@index) { // initial - @item: ~".col-@{class}-@{index}"; - .col((@index + 1), @item); - } - .col(@index, @list) when (@index =< @grid-columns) { // general - @item: ~".col-@{class}-@{index}"; - .col((@index + 1), ~"@{list}, @{item}"); - } - .col(@index, @list) when (@index > @grid-columns) { // terminal - @{list} { - float: right; - } - } - .col(1); // kickstart it -} - -.calc-rtl-grid-column(@index, @class, @type) when (@type = width) and (@index > 0) { - .col-@{class}-@{index} { - width: percentage((@index / @grid-columns)); - } -} -.calc-rtl-grid-column(@index, @class, @type) when (@type = push) and (@index > 0) { - .col-@{class}-push-@{index} { - right: percentage((@index / @grid-columns)); - left: 0; - } -} -.calc-rtl-grid-column(@index, @class, @type) when (@type = push) and (@index = 0) { - .col-@{class}-push-0 { - right: auto; - left: 0; - } -} -.calc-rtl-grid-column(@index, @class, @type) when (@type = pull) and (@index > 0) { - .col-@{class}-pull-@{index} { - left: percentage((@index / @grid-columns)); - right: auto; - } -} -.calc-rtl-grid-column(@index, @class, @type) when (@type = pull) and (@index = 0) { - .col-@{class}-pull-0 { - left: auto; - right: auto; - } -} -.calc-rtl-grid-column(@index, @class, @type) when (@type = offset) { - .col-@{class}-offset-@{index} { - margin-right: percentage((@index / @grid-columns)); - margin-left: 0; - } -} - -// Basic looping in LESS -.loop-rtl-grid-columns(@index, @class, @type) when (@index >= 0) { - .calc-rtl-grid-column(@index, @class, @type); - // next iteration - .loop-rtl-grid-columns((@index - 1), @class, @type); -} - -// Create grid for specific class -.make-rtl-grid(@class) { - .float-rtl-grid-columns(@class); - .loop-rtl-grid-columns(@grid-columns, @class, width); - .loop-rtl-grid-columns(@grid-columns, @class, pull); - .loop-rtl-grid-columns(@grid-columns, @class, push); - .loop-rtl-grid-columns(@grid-columns, @class, offset); -} diff --git a/frontend/less/espo-rtl/bootstrap-rtl/mixins/grid-rtl.less b/frontend/less/espo-rtl/bootstrap-rtl/mixins/grid-rtl.less deleted file mode 100644 index 58ef91ef80d..00000000000 --- a/frontend/less/espo-rtl/bootstrap-rtl/mixins/grid-rtl.less +++ /dev/null @@ -1,98 +0,0 @@ -// RTL Grid system -// -// Generate semantic rtl grid columns with these mixins. - -// Generate the extra small columns -.make-xs-column(@columns) { - float: right; -} -.make-xs-column-offset(@columns) { - margin-right: percentage((@columns / @grid-columns)); - margin-left: 0; -} -.make-xs-column-push(@columns) { - right: percentage((@columns / @grid-columns)); - left: auto; -} -.make-xs-column-pull(@columns) { - left: percentage((@columns / @grid-columns)); - right: auto; -} - -// Generate the small columns -.make-sm-column(@columns) { - - @media (min-width: @screen-sm-min) { - float: right; - } -} -.make-sm-column-offset(@columns) { - @media (min-width: @screen-sm-min) { - margin-right: percentage((@columns / @grid-columns)); - margin-left: 0; - } -} -.make-sm-column-push(@columns) { - @media (min-width: @screen-sm-min) { - right: percentage((@columns / @grid-columns)); - left: auto; - } -} -.make-sm-column-pull(@columns) { - @media (min-width: @screen-sm-min) { - left: percentage((@columns / @grid-columns)); - right: auto; - } -} - -// Generate the medium columns -.make-md-column(@columns) { - - @media (min-width: @screen-md-min) { - float: right; - } -} -.make-md-column-offset(@columns) { - @media (min-width: @screen-md-min) { - margin-right: percentage((@columns / @grid-columns)); - margin-left: 0; - } -} -.make-md-column-push(@columns) { - @media (min-width: @screen-md-min) { - right: percentage((@columns / @grid-columns)); - left: auto; - } -} -.make-md-column-pull(@columns) { - @media (min-width: @screen-md-min) { - left: percentage((@columns / @grid-columns)); - right: auto; - } -} - -// Generate the large columns -.make-lg-column(@columns) { - - @media (min-width: @screen-lg-min) { - float: right; - } -} -.make-lg-column-offset(@columns) { - @media (min-width: @screen-lg-min) { - margin-right: percentage((@columns / @grid-columns)); - margin-left: 0; - } -} -.make-lg-column-push(@columns) { - @media (min-width: @screen-lg-min) { - right: percentage((@columns / @grid-columns)); - left: auto; - } -} -.make-lg-column-pull(@columns) { - @media (min-width: @screen-lg-min) { - left: percentage((@columns / @grid-columns)); - right: auto; - } -} diff --git a/frontend/less/espo-rtl/bootstrap-rtl/modals-rtl.less b/frontend/less/espo-rtl/bootstrap-rtl/modals-rtl.less deleted file mode 100644 index 0a373c8385d..00000000000 --- a/frontend/less/espo-rtl/bootstrap-rtl/modals-rtl.less +++ /dev/null @@ -1,27 +0,0 @@ -// -// RTL Modals -// -------------------------------------------------- - -// Footer (for actions) -.modal-footer { - text-align: left; // right align buttons - - &.flip { - text-align: right; - } - // Properly space out buttons - .btn + .btn { - margin-left: auto; - margin-right: 5px; - } - // but override that for button groups - .btn-group .btn + .btn { - margin-right: -1px; - margin-left: auto; - } - // and override it for block buttons as well - .btn-block + .btn-block { - margin-right: 0; - margin-left: auto; - } -} diff --git a/frontend/less/espo-rtl/bootstrap-rtl/navbar-rtl.less b/frontend/less/espo-rtl/bootstrap-rtl/navbar-rtl.less deleted file mode 100644 index fe4c0405142..00000000000 --- a/frontend/less/espo-rtl/bootstrap-rtl/navbar-rtl.less +++ /dev/null @@ -1,83 +0,0 @@ -// -// RTL Navbars -// -------------------------------------------------- - - -// Navbar heading -// -// Groups `.navbar-brand` and `.navbar-toggle` into a single component for easy -// styling of responsive aspects. - -.navbar-header { - - @media (min-width: @grid-float-breakpoint) { - float: right; - } -} - -// Brand/project name - -.navbar-brand { - float: right; -} - - -// Navbar toggle -// -// Custom button for toggling the `.navbar-collapse`, powered by the collapse -// JavaScript plugin. - -.navbar-toggle { - float: left; -} - - -// Navbar nav links -// -// Builds on top of the `.nav` components with its own modifier class to make -// the nav the full height of the horizontal nav (above 768px). - -.navbar-nav { - - @media (max-width: @grid-float-breakpoint-max) { - // Dropdowns get custom display when collapsed - .open .dropdown-menu { - > li > a, - .dropdown-header { - padding: 5px 25px 5px 15px; - } - } - } - - // Uncollapse the nav - @media (min-width: @grid-float-breakpoint) { - float: right; - - > li { - float: right !important; - } - - } -} - -@media (min-width: @grid-float-breakpoint) { - .navbar-left { - float: right !important; - &.flip { - float: right !important; - } - } - - .navbar-right { - float: left !important; - - &.flip { - float: left !important; - } - - .dropdown-menu { - left: 0; - right: auto; - } - } -} diff --git a/frontend/less/espo-rtl/bootstrap-rtl/navs-rtl.less b/frontend/less/espo-rtl/bootstrap-rtl/navs-rtl.less deleted file mode 100644 index ad43b11de8a..00000000000 --- a/frontend/less/espo-rtl/bootstrap-rtl/navs-rtl.less +++ /dev/null @@ -1,86 +0,0 @@ -// -// Navs -// -------------------------------------------------- - - -// Base class -// -------------------------------------------------- - -.nav { - padding-right: 0; // Override default ul/ol - padding-left: initial; -} - - -// Tabs -// ------------------------- - -// Give the tabs something to sit on -.nav-tabs { - > li { - float: right; - - // Actual tabs (as links) - > a { - margin-left: auto; - margin-right: -2px; - } - } -} - - -// Pills -// ------------------------- -.nav-pills { - > li { - float: right; - - + li { - margin-right: 2px; - margin-left: auto; - } - } -} - - -// Stacked pills -.nav-stacked { - > li { - float: none; - + li { - margin-right: 0; // no need for this gap between nav items - margin-left: auto; - } - } -} - - -// Nav variations -// -------------------------------------------------- - -// Justified nav links -// ------------------------- - -.nav-justified { - - //To fix the overridden style by line 21 - > li{ - float: none; - } - - > .dropdown .dropdown-menu { - right: auto; - } -} - -// Move borders to anchors instead of bottom of list -// -// Mixin for adding on top the shared `.nav-justified` styles for our tabs -.nav-tabs-justified { - - > li > a { - // Override margin from .nav-tabs - margin-left: 0; - margin-right: auto; - } -} diff --git a/frontend/less/espo-rtl/bootstrap-rtl/normalize-rtl.less b/frontend/less/espo-rtl/bootstrap-rtl/normalize-rtl.less deleted file mode 100644 index c1250b9e7e3..00000000000 --- a/frontend/less/espo-rtl/bootstrap-rtl/normalize-rtl.less +++ /dev/null @@ -1,15 +0,0 @@ -// -// 1. Set direction to RTL -// - -html { - direction: rtl; -} - -// -// Remove default margin. -// - -body { - direction: rtl; -} diff --git a/frontend/less/espo-rtl/bootstrap-rtl/panels-rtl.less b/frontend/less/espo-rtl/bootstrap-rtl/panels-rtl.less deleted file mode 100644 index 13e1128b146..00000000000 --- a/frontend/less/espo-rtl/bootstrap-rtl/panels-rtl.less +++ /dev/null @@ -1,70 +0,0 @@ -// -// RTL Panels -// -------------------------------------------------- - -// Tables in panels -// -// Place a non-bordered `.table` within a panel (not within a `.panel-body`) and -// watch it go full width. - -.panel { - // Add border top radius for first one - > .table:first-child, - > .table-responsive:first-child > .table:first-child { - - > thead:first-child, - > tbody:first-child { - > tr:first-child { - td:first-child, - th:first-child { - //border-top-right-radius: (@panel-border-radius); - //border-top-left-radius: 0; - } - td:last-child, - th:last-child { - //border-top-left-radius: (@panel-border-radius); - //border-top-right-radius: 0; - } - } - } - } - // Add border bottom radius for last one - > .table:last-child, - > .table-responsive:last-child > .table:last-child { - - > tbody:last-child, - > tfoot:last-child { - > tr:last-child { - td:first-child, - th:first-child { - //border-bottom-left-radius: (@panel-border-radius); - //border-top-right-radius: 0; - } - td:last-child, - th:last-child { - //border-bottom-right-radius: (@panel-border-radius); - //border-top-left-radius: 0; - } - } - } - } - > .table-bordered, - > .table-responsive > .table-bordered { - > thead, - > tbody, - > tfoot { - > tr { - > th:first-child, - > td:first-child { - border-right: 0; - border-left: none; - } - > th:last-child, - > td:last-child { - border-right: none; - border-left: 0; - } - } - } - } -} diff --git a/frontend/less/espo-rtl/bootstrap-rtl/popovers-rtl.less b/frontend/less/espo-rtl/bootstrap-rtl/popovers-rtl.less deleted file mode 100644 index 8de29cb1376..00000000000 --- a/frontend/less/espo-rtl/bootstrap-rtl/popovers-rtl.less +++ /dev/null @@ -1,27 +0,0 @@ -.popover { - left: auto; - text-align: right; -} - -.popover { - &.top > .arrow { - right: 50%; - left: auto; - margin-right: var(--popover-arrow-outer-width-negative); - margin-left: auto; - &:after { - margin-right: var(--popover-arrow-width-negative); - margin-left: auto; - } - } - &.bottom > .arrow { - right: 50%; - left: auto; - margin-right: var(--popover-arrow-outer-width-negative); - margin-left: auto; - &:after { - margin-right: var(--popover-arrow-width-negative); - margin-left: auto; - } - } -} diff --git a/frontend/less/espo-rtl/bootstrap-rtl/progress-bars-rtl.less b/frontend/less/espo-rtl/bootstrap-rtl/progress-bars-rtl.less deleted file mode 100644 index 9b5b9f76e0c..00000000000 --- a/frontend/less/espo-rtl/bootstrap-rtl/progress-bars-rtl.less +++ /dev/null @@ -1,8 +0,0 @@ -// -// RTL Progress bars -// -------------------------------------------------- - -// Bar of progress -.progress-bar { - float: right; -} diff --git a/frontend/less/espo-rtl/bootstrap-rtl/responsive-embed-rtl.less b/frontend/less/espo-rtl/bootstrap-rtl/responsive-embed-rtl.less deleted file mode 100644 index 87bc416db04..00000000000 --- a/frontend/less/espo-rtl/bootstrap-rtl/responsive-embed-rtl.less +++ /dev/null @@ -1,15 +0,0 @@ -// RTL Embeds responsive -// -// Credit: Nicolas Gallagher and SUIT CSS. - -.embed-responsive { - - .embed-responsive-item, - iframe, - embed, - object { - right: 0; - left: auto; - } - -} diff --git a/frontend/less/espo-rtl/bootstrap-rtl/tables-rtl.less b/frontend/less/espo-rtl/bootstrap-rtl/tables-rtl.less deleted file mode 100644 index 956e6ade469..00000000000 --- a/frontend/less/espo-rtl/bootstrap-rtl/tables-rtl.less +++ /dev/null @@ -1,47 +0,0 @@ -// -// Tables -// -------------------------------------------------- - -//TODO -caption { - text-align: right; -} - -th { - text-align: right; -} - -// Responsive tables -// -// Wrap your tables in `.table-responsive` and we'll make them mobile friendly -// by enabling horizontal scrolling. Only applies <768px. Everything above that -// will display normally. - -.table-responsive { - @media screen and (max-width: @screen-xs-max) { - - // Special overrides for the bordered tables - > .table-bordered { - border: 0; - - // Nuke the appropriate borders so that the parent can handle them - > thead, - > tbody, - > tfoot { - > tr { - > th:first-child, - > td:first-child { - border-right: 0; - border-left: initial; - } - > th:last-child, - > td:last-child { - border-left: 0; - border-right: initial; - } - } - } - - } - } -} diff --git a/frontend/less/espo-rtl/bootstrap-rtl/type-rtl.less b/frontend/less/espo-rtl/bootstrap-rtl/type-rtl.less deleted file mode 100644 index 0b4af781f66..00000000000 --- a/frontend/less/espo-rtl/bootstrap-rtl/type-rtl.less +++ /dev/null @@ -1,45 +0,0 @@ -// -// RTL Typography -// -------------------------------------------------- - -// Flipped Alignment -.flip.text-left { text-align: right; } -.flip.text-right { text-align: left; } - -// List options - -// Unstyled keeps list items block level, just removes default browser padding and list-style -.list-unstyled { - padding-right: 0; - padding-left: initial; -} - -// Inline turns list items into inline-block -.list-inline { - .list-unstyled(); - margin-right: -5px; - margin-left: 0; -} - -dd { - margin-right: 0; // Undo browser default - margin-left: initial; -} - -// Blockquotes -blockquote { - border-right: 5px solid @blockquote-border-color; - border-left: 0; -} - -// Opposite alignment of blockquote -// -// Heads up: `blockquote.pull-right` has been deprecated as of v3.1.0. -.blockquote-reverse, -blockquote.pull-left { - padding-left: 15px; - padding-right: 0; - border-left: 5px solid @blockquote-border-color; - border-right: 0; - text-align: left; -} diff --git a/frontend/less/espo-rtl/bootstrap-rtl/utilities-rtl.less b/frontend/less/espo-rtl/bootstrap-rtl/utilities-rtl.less deleted file mode 100644 index eaf8db421c0..00000000000 --- a/frontend/less/espo-rtl/bootstrap-rtl/utilities-rtl.less +++ /dev/null @@ -1,15 +0,0 @@ -// -// Temporary RTL style to fix bugs rapidly. -// They will move to some place more appropriate later. -// -------------------------------------------------- - -.pull-right { - &.flip { - float: left !important; - } -} -.pull-left { - &.flip { - float: right !important; - } -} diff --git a/frontend/less/espo-rtl/bootstrap-rtl/variables-rtl.less b/frontend/less/espo-rtl/bootstrap-rtl/variables-rtl.less deleted file mode 100644 index 13afd053b7c..00000000000 --- a/frontend/less/espo-rtl/bootstrap-rtl/variables-rtl.less +++ /dev/null @@ -1,8 +0,0 @@ -// -// RTL Variables -// -------------------------------------------------- - - -//== Media Objects on pull-left/pull-right + flip -//REMOVED @media-pull-margin: 10px; - diff --git a/frontend/less/espo-rtl/iframe/main.less b/frontend/less/espo-rtl/iframe/main.less deleted file mode 100644 index 265a4f60220..00000000000 --- a/frontend/less/espo-rtl/iframe/main.less +++ /dev/null @@ -1,7 +0,0 @@ -@import "../../espo/bootstrap/variables.less"; -@import "../../espo/value-variables.less"; -@import "../../espo/variables.less"; -@import "../variables.less"; -@import "../../espo/root-variables.less"; -@import "../../espo/fonts.less"; -@import "../../espo/iframe/iframe.less"; diff --git a/frontend/less/espo-rtl/main.less b/frontend/less/espo-rtl/main.less deleted file mode 100644 index e19b4618835..00000000000 --- a/frontend/less/espo-rtl/main.less +++ /dev/null @@ -1,19 +0,0 @@ -@import "../espo/init.less"; -@import "bootstrap-rtl/bootstrap-rtl.less"; -@import "../espo/value-variables.less"; -@import "../espo/variables.less"; -@import "variables.less"; -@import "../espo/root-variables.less"; -@import "../espo/mixins.less"; -@import "../espo/misc.less"; -@import "../espo/layout-top.less"; -@import "../espo/layout-side.less"; -@import "../espo/custom.less"; -@import "layout-top.less"; -@import "layout-side.less"; -@import "custom.less"; -@import "bootstrap-rtl/utilities-rtl.less"; - -body { - --theme-name: EspoRtl; -} diff --git a/frontend/less/espo-rtl/variables.less b/frontend/less/espo-rtl/variables.less deleted file mode 100644 index 15e61e24afc..00000000000 --- a/frontend/less/espo-rtl/variables.less +++ /dev/null @@ -1,3 +0,0 @@ -@border-radius-value: var(--6px); -@panel-border-radius-value: var(--6px); -@dropdown-border-radius-value: var(--4px); diff --git a/frontend/less/espo/bootstrap/utilities.less b/frontend/less/espo/bootstrap/utilities.less index 7a8ca27a8ff..3a6909f2faa 100644 --- a/frontend/less/espo/bootstrap/utilities.less +++ b/frontend/less/espo/bootstrap/utilities.less @@ -18,6 +18,12 @@ .pull-left { float: left !important; } +.pull-start { + float: inline-start !important; +} +.pull-end { + float: inline-end !important; +} // Toggling content diff --git a/frontend/less/espo/custom.less b/frontend/less/espo/custom.less index 93bbeff1200..a36862fa518 100644 --- a/frontend/less/espo/custom.less +++ b/frontend/less/espo/custom.less @@ -1769,7 +1769,7 @@ table.less-padding td.cell[data-name="buttons"] > .btn-group { } .show-more > .btn-block { - text-align: left; + text-align: start; padding: var(--7px) var(--10px) var(--7px) var(--panel-padding); &, @@ -2461,7 +2461,7 @@ div.field, td.cell { > img.avatar-link { - margin-right: var(--6px); + margin-inline-end: var(--6px); } } @@ -3742,7 +3742,7 @@ table.table-admin-panel { @media screen and (min-width: @screen-sm-min) { .search-container .view-mode-switcher-buttons-group { - float: right; + float: inline-end; } } @@ -3901,7 +3901,7 @@ a.field-info > span.fa-info-circle { .btn:not(.btn-icon) > .far:first-child:has(+ span), .btn:not(.btn-icon) > .fas:first-child:has(+ span) { - padding-right: var(--5px); + padding-inline-end: var(--5px); &.fas, &.far { @@ -4426,12 +4426,12 @@ body > .autocomplete-suggestions.text-search-suggestions { position: relative; input { - padding-right: var(--32px); + padding-inline-end: var(--32px); } a[data-action="toggleShowPassword"] { position: absolute; - right: var(--8px); + inset-inline-end: var(--8px); top: var(--8px); user-select: none; width: var(--22px); diff --git a/frontend/less/espo/main.less b/frontend/less/espo/main.less index 1e9bd73cfc8..d2bb1a1e955 100644 --- a/frontend/less/espo/main.less +++ b/frontend/less/espo/main.less @@ -8,6 +8,7 @@ @import "layout-side.less"; @import "custom.less"; @import "utilities.less"; +@import "rtl/main.less"; body { --theme-name: Espo; diff --git a/frontend/less/espo/misc/kanban.less b/frontend/less/espo/misc/kanban.less index b4cba05e83a..28a4f95913f 100644 --- a/frontend/less/espo/misc/kanban.less +++ b/frontend/less/espo/misc/kanban.less @@ -21,7 +21,7 @@ div.list-kanban > div > table { } > div { - padding-left: var(--table-cell-padding); + padding-inline-start: var(--table-cell-padding); position: relative; line-height: var(--28px); height: 100%; diff --git a/frontend/less/espo/rtl/bootstrap.less b/frontend/less/espo/rtl/bootstrap.less new file mode 100644 index 00000000000..f094ae6eca1 --- /dev/null +++ b/frontend/less/espo/rtl/bootstrap.less @@ -0,0 +1,444 @@ +// Compact Bootstrap 3 RTL adjustments used by EspoCRM. +// Native `dir=rtl` handles inline flow; physical Bootstrap declarations are +// corrected here without changing the shared LTR styles. + +span, +a, +label, +strong, +small { + unicode-bidi: isolate; +} + +[class^="col-xs-"], +[class*=" col-xs-"] { + float: right; +} + +@media (min-width: @screen-sm-min) { + [class^="col-sm-"], + [class*=" col-sm-"] { + float: right; + } + + .col-sm-offset-2 { + margin-left: 0; + margin-right: percentage((2 / @grid-columns)); + } + + .form-horizontal .control-label { + text-align: left; + } + + .navbar-header, + .navbar-brand, + .navbar-nav, + .navbar-nav > li { + float: right; + } + + .navbar-right { + float: left !important; + margin-left: calc(var(--grid-gutter-width-half) * -1); + margin-right: 0; + + .dropdown-menu { + left: 0; + right: auto; + } + } + + .navbar-left { + float: right !important; + } +} + +@media (min-width: @screen-md-min) { + [class^="col-md-"], + [class*=" col-md-"] { + float: right; + } +} + +@media (min-width: @screen-lg-min) { + [class^="col-lg-"], + [class*=" col-lg-"] { + float: right; + } +} + +.nav-tabs > li, +.btn-group > .btn, +.pagination > li > a, +.pagination > li > span { + float: right; +} + +.btn-toolbar .btn, +.btn-toolbar .btn-group, +.btn-toolbar .input-group, +.btn-group > .btn-group, +.input-group .form-control, +.progress-bar { + float: right; +} + +.btn-toolbar { + margin-left: 0; + margin-right: var(--minus-5px); + + > .btn, + > .btn-group, + > .input-group { + margin-left: 0; + margin-right: var(--5px); + } +} + +.btn-group > .btn:first-child:not(:last-child):not(.dropdown-toggle), +.input-group .form-control:first-child, +.input-group-addon:first-child, +.input-group-btn:first-child > .btn, +.pagination > li:first-child > a, +.pagination > li:first-child > span { + border-bottom-left-radius: 0; + border-top-left-radius: 0; + border-bottom-right-radius: var(--border-radius); + border-top-right-radius: var(--border-radius); +} + +.btn-group > .btn:last-child:not(:first-child), +.btn-group > .dropdown-toggle:not(:first-child), +.input-group .form-control:last-child, +.input-group-addon:last-child, +.input-group-btn:last-child > .btn, +.pagination > li:last-child > a, +.pagination > li:last-child > span { + border-bottom-left-radius: var(--border-radius); + border-top-left-radius: var(--border-radius); + border-bottom-right-radius: 0; + border-top-right-radius: 0; +} + +.btn-group > .btn-group:first-child:not(:last-child) > .btn:last-child, +.btn-group > .btn-group:first-child:not(:last-child) > .dropdown-toggle { + border-bottom-left-radius: 0; + border-top-left-radius: 0; + border-bottom-right-radius: var(--border-radius); + border-top-right-radius: var(--border-radius); +} + +.btn-group > .btn-group:last-child:not(:first-child) > .btn:first-child { + border-bottom-left-radius: var(--border-radius); + border-top-left-radius: var(--border-radius); + border-bottom-right-radius: 0; + border-top-right-radius: 0; +} + +.input-group-addon:first-child { + border-left: 0; + border-right: var(--input-border-width) solid var(--input-border); +} + +.input-group-addon:last-child { + border-left: var(--input-border-width) solid var(--input-border); + border-right: 0; +} + +.input-group-btn { + > .btn + .btn { + margin-left: 0; + margin-right: var(--minus-1px); + } + + &:first-child > .btn, + &:first-child > .btn-group { + margin-left: var(--minus-1px); + margin-right: 0; + } + + &:last-child > .btn, + &:last-child > .btn-group { + margin-left: 0; + margin-right: var(--minus-1px); + } +} + +.caret { + margin-left: 0; + margin-right: var(--2px); +} + +.dropdown-menu { + text-align: right; + + &.pull-right { + left: 0; + right: auto; + } +} + +.nav, +.list-group { + padding-left: initial; + padding-right: 0; +} + +.nav-tabs > li > a { + margin-left: auto; + margin-right: var(--minus-2px); +} + +.radio, +.checkbox { + label { + padding-left: 0; + padding-right: var(--20px); + } +} + +.radio input[type="radio"], +.checkbox input[type="checkbox"] { + margin-left: auto; + margin-right: calc(var(--20px) * -1); +} + +.has-feedback .form-control { + padding-left: calc(var(--input-height-base) * 1.25); + padding-right: var(--12px); +} + +.form-control-feedback { + left: 0; + right: auto; +} + +.form-horizontal .has-feedback .form-control-feedback { + left: var(--grid-gutter-width-half); + right: auto; +} + +caption, +th { + text-align: right; +} + +blockquote { + border-left: 0; + border-right: var(--3px) solid var(--blockquote-border-color); +} + +.navbar-toggle { + float: left; + margin-left: var(--15px); + margin-right: 0; +} + +.media-left { + padding-left: var(--10px); + padding-right: 0; +} + +.media-right { + padding-left: 0; + padding-right: var(--10px); +} + +.list-group-item > .badge, +.close { + float: left; +} + +.list-group-item > .badge + .badge { + margin-left: var(--5px); + margin-right: 0; +} + +.pager .previous > a, +.pager .previous > span { + float: right; +} + +.pager .next > a, +.pager .next > span { + float: left; +} + +// Typography + +.list-unstyled, +.list-inline { + padding-right: 0; +} + +.list-inline { + margin-right: var(--minus-5px); + margin-left: 0; +} + +dd { + margin-right: 0; +} + +.blockquote-reverse { + padding-left: var(--15px); + padding-right: 0; + border-left: var(--5px) solid var(--blockquote-border-color); + border-right: 0; + text-align: left; +} + +// Tables + +.table-responsive { + @media screen and (max-width: @screen-xs-max) { + > .table-bordered > :is(thead, tbody, tfoot) > tr { + > :is(th, td):first-child:not(:last-child) { + border-left: var(--1px) solid @table-border-color; + border-right: 0; + } + + > :is(th, td):last-child:not(:first-child) { + border-left: 0; + border-right: var(--1px) solid @table-border-color; + } + } + } +} + +// Forms + +.radio-inline, +.checkbox-inline { + padding-left: 0; + padding-right: var(--20px); +} + +.radio-inline input[type="radio"], +.checkbox-inline input[type="checkbox"] { + margin-left: auto; + margin-right: calc(var(--20px) * -1); +} + +.radio-inline + .radio-inline, +.checkbox-inline + .checkbox-inline { + margin-left: 0; + margin-right: var(--10px); +} + +// Button groups + +.btn-group { + .btn + .btn, + .btn + .btn-group, + .btn-group + .btn, + .btn-group + .btn-group { + margin-left: 0; + margin-right: var(--minus-1px); + } +} + +.btn-group.btn-group-justified > .btn, +.btn-group.btn-group-justified > .btn-group { + float: none; +} + +.btn .caret { + margin-right: 0; +} + +// Input groups + +.input-group-btn:first-child > .btn-group > .btn, +.input-group-btn:first-child > .dropdown-toggle, +.input-group-btn:last-child > .btn:not(:last-child):not(.dropdown-toggle), +.input-group-btn:last-child > .btn-group:not(:last-child) > .btn { + border-bottom-left-radius: 0; + border-top-left-radius: 0; + border-bottom-right-radius: var(--border-radius); + border-top-right-radius: var(--border-radius); +} + +.input-group-btn:last-child > .btn-group > .btn, +.input-group-btn:last-child > .dropdown-toggle, +.input-group-btn:first-child > .btn:not(:first-child), +.input-group-btn:first-child > .btn-group:not(:first-child) > .btn { + border-bottom-left-radius: var(--border-radius); + border-top-left-radius: var(--border-radius); + border-bottom-right-radius: 0; + border-top-right-radius: 0; +} + +// Navs + +.nav-justified > li { + float: none; +} + +.nav-tabs-justified > li > a { + margin-left: 0; + margin-right: auto; +} + +// Navbar + +.navbar-brand { + float: right; +} + +@media (max-width: @grid-float-breakpoint-max) { + .navbar-nav .open .dropdown-menu { + > li > a, + .dropdown-header { + padding: var(--5px) var(--25px) var(--5px) var(--15px); + } + } +} + +// Badges + +.nav-pills > li > a > .badge { + margin-left: 0; + margin-right: var(--3px); +} + +// Media objects + +.media-list { + padding-right: 0; +} + +// Panels + +.panel { + > .table-bordered, + > .table-responsive > .table-bordered { + > :is(thead, tbody, tfoot) > tr { + > :is(th, td):first-child:not(:last-child) { + border-left: var(--1px) solid @table-border-color; + border-right: 0; + } + + > :is(th, td):last-child:not(:first-child) { + border-left: 0; + border-right: var(--1px) solid @table-border-color; + } + } + } +} + +// Modals + +.modal-footer { + text-align: left; + + .btn + .btn { + margin-left: 0; + margin-right: var(--5px); + } + + .btn-group .btn + .btn { + margin-left: 0; + margin-right: var(--minus-1px); + } +} diff --git a/frontend/less/espo-rtl/custom.less b/frontend/less/espo/rtl/custom.less similarity index 69% rename from frontend/less/espo-rtl/custom.less rename to frontend/less/espo/rtl/custom.less index a7ef8578209..fd5accaf5fd 100644 --- a/frontend/less/espo-rtl/custom.less +++ b/frontend/less/espo/rtl/custom.less @@ -1,5 +1,5 @@ .dropdown-menu { - left: auto; + left: auto; right: 0; } @@ -13,16 +13,29 @@ right: 0; } +body > .alert.alert-closable { + .message { + float: right; + } + + .close-container { + float: left; + } +} + .list .checkbox-dropdown { - margin-left: -0; - margin-right: 0; - right: 16px; + margin-right: var(--2px); + margin-left: 0; } .list-buttons-container > div { - float: right; - margin-left: var(--panel-padding); - margin-right: 0; + &.settings-container.pull-right { + float: left !important; + } + + &.pagination { + float: left; + } } .list-buttons-container > div.total-count { @@ -30,6 +43,10 @@ padding: 6px 0; } +.btn-group > a.pagination-btn { + float: left; +} + li .preset .pull-right { float: left !important; } @@ -53,12 +70,21 @@ ul.dropdown-menu > li.checkbox { } .modal-body > div > .list > table td:first-child, -.modal-body > div > .list > table th:first-child { - padding-right: var(--panel-padding); - padding-left: var(--table-cell-padding-half); +.modal-body > div > .list > table th:first-child, +#main > .list-container > .list > table td:first-child, +#main > .list-container > .list > table th:first-child, +.panel-body .no-side-margin > table td:first-child, +.panel-body .no-side-margin > table th:first-child { + padding-right: var(--panel-padding); + padding-left: var(--table-cell-padding-half); } + .modal-body > div > .list > table td:last-child, -.modal-body > div > .list > table th:last-child { +.modal-body > div > .list > table th:last-child, +#main > .list-container > .list > table td:last-child, +#main > .list-container > .list > table th:last-child, +.panel-body .no-side-margin > table td:last-child, +.panel-body .no-side-margin > table th:last-child { padding-left: var(--panel-padding); padding-right: var(--table-cell-padding-half); } @@ -67,30 +93,6 @@ ul.dropdown-menu > li.checkbox { margin-left: calc(var(--container-padding) * -1); margin-right: calc(var(--container-padding) * -1); } -#main > .list-container > .list > table td:first-child, -#main > .list-container > .list > table th:first-child { - padding-right: var(--panel-padding); - padding-left: var(--table-cell-padding-half); -} -#main > .list-container > .list > table td:last-child, -#main > .list-container > .list > table th:last-child { - padding-left: var(--panel-padding); - padding-right: var(--table-cell-padding-half); -} - -.panel-body .no-side-margin { - > table td:first-child, - > table th:first-child { - padding-right: var(--panel-padding); - padding-left: var(--table-cell-padding-half); - } - - > table td:last-child, - > table th:last-child { - padding-left: var(--panel-padding); - padding-right: var(--table-cell-padding-half); - } -} .panel > .panel-heading > .btn-group { right: 0; @@ -101,11 +103,8 @@ ul.dropdown-menu > li.checkbox { } .detail-button-container > .pull-right, -.edit-button-container > .pull-right -{ - float: left !important; -} - +.edit-button-container > .pull-right, +.layout-container > [data-role="layoutRow"] > .btn-group.pull-right, .header-buttons.pull-right { float: left !important; } @@ -118,16 +117,20 @@ ul.dropdown-menu > li.checkbox { direction: ltr; } -.calendar-container .button-container > div > div.pull-right { +.calendar-container .button-container > div > div.pull-right, +.dropdown-menu > li > a > span.pull-right { float: left !important; } -.dropdown-menu > li > a > span.pull-right { - float: left !important; +.button-container [data-action="previous"], +.button-container [data-action="next"] { + transform: scaleX(-1); } -.panel > .panel-heading > .pull-right.btn-group { +.panel > .panel-heading > .pull-right.btn-group, +.panel > .panel-heading > .pull-right.dropdown { float: left !important; + .dropdown-menu { left: 0; right: auto; @@ -140,21 +143,17 @@ ul.dropdown-menu > li.checkbox { } .panel > .panel-heading > .pull-right.dropdown { - float: left !important; right: 0; margin-left: -11px; - .dropdown-menu { - left: 0; - right: auto; - } } -.panel .panel-heading > a.pull-right { +.panel .panel-heading > a.pull-right, +.cell > a.pull-right { float: left !important; } -.cell > a.pull-right { - float: left !important; +.cell:has(> .inline-edit-link):has(> .field) .control-label { + float: right; } .inline-save-link { @@ -165,6 +164,20 @@ ul.dropdown-menu > li.checkbox { margin-left: 0; } +.cell { + .inline-save-link { + float: left; + margin-left: 0; + margin-right: var(--8px); + } + + .inline-cancel-link { + float: left; + margin-left: 0; + margin-right: var(--26px); + } +} + td.cell[data-name="buttons"] > .btn-group { margin-right: -17px; } @@ -173,20 +186,21 @@ td.cell[data-name="buttons"] > .btn-group { margin-right: -14px; } -.list-expanded > .list-group > .list-group-item > .pull-right { +.list-expanded > .list-group > .list-group-item > .pull-right, +.list-expanded > .list-group > .list-group-item > .pull-right > .list-row-buttons { float: left !important; } .list-expanded > .list-group > .list-group-item > .pull-right > .list-row-buttons { - float: left !important; margin-right: 0; margin-left: -16px; } -.link-container > .list-group-item > .pull-right { +[data-action="showMore"] .more-count { float: left !important; } +.link-container > .list-group-item > .pull-right, .link-container > .list-group-item > .pull-left > .pull-right { float: left !important; } @@ -200,14 +214,19 @@ td.cell[data-name="buttons"] > .btn-group { } #global-search-panel, -#last-viewed-panel { - left: 35px; +#last-viewed-panel, +#notifications-panel { + left: 0; right: auto; } +#last-viewed-panel > .panel, +#notifications-panel > .panel { + border-top-left-radius: 0; + border-top-right-radius: var(--panel-border-radius); +} + #notifications-panel { - left: 0; - right: auto; .panel .panel-heading .pull-right { float: left !important; } @@ -225,22 +244,10 @@ td.cell[data-name="buttons"] > .btn-group { direction: rtl; } -.dynamic-logic-expression-container { - .pull-right { - float: left !important; - } -} - -#fields-panel .cell .field { - .pull-right { - float: left !important; - } -} - -.gray-box { - .pull-right { - float: left !important; - } +.dynamic-logic-expression-container .pull-right, +#fields-panel .cell .field .pull-right, +.gray-box .pull-right { + float: left !important; } .panel.dashlet > .panel-heading > .btn-group { @@ -254,6 +261,33 @@ ul { padding-left: 0; padding-right: var(--8px); } + + &.dropdown-menu-with-icons, + &:has(> li:not(.hidden) > a span.item-icon) { + > li:not(:has(.item-icon-grid)) { + a { + padding-left: var(--20px); + padding-right: var(--12px); + } + + .item-text { + padding-left: 0; + padding-right: var(--10px); + } + } + } + + &:has(> li:not(.hidden) > a span.item-icon) { + > li > a:not(:has(span.item-icon)) { + padding-right: var(--28px); + padding-left: var(--20px); + } + } + + &.dropdown-menu > li > a > .check-icon + div { + padding-right: 0; + padding-left: var(--26px); + } } .panel { @@ -263,14 +297,17 @@ ul { > a { padding-right: 20px; } + float: left; } } } -.dropdown-menu-with-icons li a { - padding-right: 10px; - padding-left: 20px; +#navbar .navbar .menu-container { + > ul.dropdown-menu > li > a:has(> .item-user-profile) .avatar { + margin-left: var(--8px); + margin-right: 0; + } } .post-container { @@ -280,18 +317,16 @@ ul { } } -.link-multiple-item { - .link-multiple-item-icon { - margin-left: 4px; - margin-right: 0; - } -} - +.link-multiple-item .link-multiple-item-icon, .link-field-icon { margin-left: 4px; margin-right: 0; } +.stream-details-container .fa-arrow-right { + transform: scaleX(-1); +} + .array-add-list-group > li { input[type="checkbox"] { margin-left: 0; @@ -306,6 +341,10 @@ ul { } div.list-kanban > div > table { + .item .panel .item-menu-container { + float: left !important; + } + th.group-header { > div { .create-button { @@ -339,22 +378,25 @@ div.list-kanban > div > table { padding: 5px 12px 5px 0; } +.list-group-tree > li { + padding: 0 var(--panel-padding) 0 0; +} + .list > table th.action-cell { a { - left: 11px; - right: auto; + left: 11px; + right: auto; } } #main > .list-container > .list > table th.action-cell { a { - left: 9px; - right: auto; + left: 9px; + right: auto; - @media screen and (max-width: @screen-xs-max) { - right: auto; - left: 5px; - } + @media screen and (max-width: @screen-xs-max) { + left: 5px; + } } } @@ -363,13 +405,7 @@ table.table-admin-panel tr > td:first-child > div > a { padding-left: 10px; } -.input-group > .input-group-btn:not(:first-child) > .btn:first-child { - border-top-right-radius: 0 !important; - border-bottom-right-radius: 0 !important; - border-top-left-radius: var(--border-radius) !important; - border-bottom-left-radius: var(--border-radius) !important; -} - +.input-group > .input-group-btn:not(:first-child) > .btn:first-child, .input-group > .input-group-btn:last-child > .form-control:last-child { border-top-left-radius: var(--border-radius) !important; border-bottom-left-radius: var(--border-radius) !important; @@ -381,11 +417,6 @@ table.table-admin-panel tr > td:first-child > div > a { border-radius: 0 !important; } -.field .input-group > .input-group-btn > button.btn.btn-icon:last-child { - border-top-left-radius: 5px !important; - border-bottom-left-radius: 5px !important; -} - .field .input-group > input.main-element.form-control:first-child { border-top-right-radius: 5px; border-bottom-right-radius: 5px; @@ -428,6 +459,7 @@ table.table-admin-panel tr > td:first-child > div > a { &.radius-right { border-top-left-radius: var(--border-radius) !important; border-bottom-left-radius: var(--border-radius) !important; + &:not(.radius-left) { .border-right-radius(0) !important; } @@ -436,6 +468,7 @@ table.table-admin-panel tr > td:first-child > div > a { &.radius-left { border-top-right-radius: var(--border-radius) !important; border-bottom-right-radius: var(--border-radius) !important; + &:not(.radius-right) { .border-left-radius(0) !important; } @@ -456,20 +489,24 @@ table.table-admin-panel tr > td:first-child > div > a { > thead, > tbody { > tr { - > th:first-child, - > td:first-child { - padding-right: var(--padding-base-horizontal); + > th:first-child, + > td:first-child { + padding-right: var(--padding-base-horizontal); + } - @media screen and (max-width: @screen-xs-max) { - padding-right: var(--table-cell-padding); + > th:last-child, + > td:last-child { + padding-left: var(--padding-base-horizontal); } - } - > th:last-child, - > td:last-child { - padding-left: var(--padding-base-horizontal); + @media screen and (max-width: @screen-xs-max) { + > th:first-child, + > td:first-child { + padding-right: var(--table-cell-padding); + } - @media screen and (max-width: @screen-xs-max) { + > th:last-child, + > td:last-child { padding-left: var(--table-cell-padding); } } @@ -493,19 +530,10 @@ table.table-admin-panel tr > td:first-child > div > a { } } } -} -@media screen and (min-width: @screen-sm-min) { .folders-container.sticked { left: unset; right: 15px; - - .list-group-side { - > li { - //margin-left: -15px; - //margin-right: 0; - } - } } } @@ -513,10 +541,6 @@ table.table-admin-panel tr > td:first-child > div > a { float: right; } -.list .checkbox-dropdown { - right: 4px; -} - .checkbox input[type="checkbox"].form-checkbox { margin-left: 0; margin-right: -20px; diff --git a/frontend/less/espo-rtl/layout-side.less b/frontend/less/espo/rtl/layout-side.less similarity index 100% rename from frontend/less/espo-rtl/layout-side.less rename to frontend/less/espo/rtl/layout-side.less diff --git a/frontend/less/espo-rtl/layout-top.less b/frontend/less/espo/rtl/layout-top.less similarity index 92% rename from frontend/less/espo-rtl/layout-top.less rename to frontend/less/espo/rtl/layout-top.less index 0ee15c6abfc..09818d12594 100644 --- a/frontend/less/espo-rtl/layout-top.less +++ b/frontend/less/espo/rtl/layout-top.less @@ -36,10 +36,6 @@ body:not([data-navbar="side"]) { padding-left: 18px; } } - - .search-container .view-mode-switcher-buttons-group { - float: left !important; - } } @media screen and (max-width: @screen-xs-max) { @@ -69,7 +65,6 @@ body:not([data-navbar="side"]) { .kanban-row { .item-menu-container.pull-right { - float: left !important; left: -9px; margin-left: -10px; margin-right: 0; diff --git a/frontend/less/espo/rtl/main.less b/frontend/less/espo/rtl/main.less new file mode 100644 index 00000000000..22b84c32155 --- /dev/null +++ b/frontend/less/espo/rtl/main.less @@ -0,0 +1,6 @@ +html[dir="rtl"] { + @import "bootstrap.less"; + @import "layout-top.less"; + @import "layout-side.less"; + @import "custom.less"; +} diff --git a/frontend/test/spec/test.theme-manager.js b/frontend/test/spec/test.theme-manager.js index 57053d2094f..5c5f10f39bd 100644 --- a/frontend/test/spec/test.theme-manager.js +++ b/frontend/test/spec/test.theme-manager.js @@ -55,6 +55,14 @@ describe('theme-manager', () => { "side", "top" ] + }, + "direction": { + "type": "enum", + "default": "ltr", + "options": [ + "ltr", + "rtl" + ] } }, "mappedParams": { @@ -149,6 +157,48 @@ describe('theme-manager', () => { expect(themeManager.getParam('navbar')).toBe('top'); }); + it('direction default', () => { + spyOn(preferences, 'get').and.callFake(name => { + if (name === 'theme') { + return 'Espo'; + } + }); + + expect(themeManager.getDirection()).toBe('ltr'); + }); + + it('direction from preferences', () => { + spyOn(preferences, 'get').and.callFake(name => { + if (name === 'theme') { + return 'Espo'; + } + + if (name === 'themeParams') { + return { + direction: 'rtl', + }; + } + }); + + expect(themeManager.getDirection()).toBe('rtl'); + }); + + it('invalid direction', () => { + spyOn(preferences, 'get').and.callFake(name => { + if (name === 'theme') { + return 'Espo'; + } + + if (name === 'themeParams') { + return { + direction: 'invalid', + }; + } + }); + + expect(themeManager.getDirection()).toBe('ltr'); + }); + it('mapped param 1', () => { spyOn(preferences, 'get').and.callFake(name => { if (name === 'theme') { diff --git a/html/main.html b/html/main.html index 4ddcd63a65c..5d39f7e0685 100644 --- a/html/main.html +++ b/html/main.html @@ -1,5 +1,5 @@ - + {{applicationName}} {{scriptsHtml}} @@ -51,7 +51,7 @@ }); - +